From 24433661d287186aa9a4f15321d1bec97f3a25da Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Wed, 25 Jun 2025 19:05:37 +0300 Subject: [PATCH 01/10] Revert bigint changes --- src/token/fungible/TokenCoinData.ts | 31 ++--- src/transaction/OfflineTransaction.ts | 27 ---- src/utils/JsonUtils.ts | 56 -------- .../token/fungible/BigIntSerializationTest.ts | 73 ---------- .../token/fungible/TokenCoinDataBigIntTest.ts | 86 ------------ .../unit/token/fungible/TokenCoinDataTest.ts | 9 ++ .../OfflineTransactionBigIntFixTest.ts | 131 ------------------ tests/unit/utils/JsonUtilsTest.ts | 65 --------- 8 files changed, 22 insertions(+), 456 deletions(-) delete mode 100644 src/utils/JsonUtils.ts delete mode 100644 tests/unit/token/fungible/BigIntSerializationTest.ts delete mode 100644 tests/unit/token/fungible/TokenCoinDataBigIntTest.ts create mode 100644 tests/unit/token/fungible/TokenCoinDataTest.ts delete mode 100644 tests/unit/transaction/OfflineTransactionBigIntFixTest.ts delete mode 100644 tests/unit/utils/JsonUtilsTest.ts diff --git a/src/token/fungible/TokenCoinData.ts b/src/token/fungible/TokenCoinData.ts index d99cbe29..df2ea571 100644 --- a/src/token/fungible/TokenCoinData.ts +++ b/src/token/fungible/TokenCoinData.ts @@ -16,7 +16,7 @@ export class TokenCoinData implements ISerializable { private readonly _coins: Map; /** - * @param coins Array of coin id and balance pairs + * @param coins Array of coin id serialized to bigint and balance pairs */ public constructor(coins: [bigint, bigint][]) { this._coins = new Map(coins); @@ -31,6 +31,10 @@ export class TokenCoinData implements ISerializable { return new Map(Array.from(this._coins.entries()).map(([key, value]) => [CoinId.fromBigInt(key), value])); } + /** + * Create a new coin data object from an array of coin id and balance pairs. + * @param coins Array of tuples of CoinId and bigint. + */ public static create(coins: [CoinId, bigint][]): TokenCoinData { return new TokenCoinData(coins.map(([key, value]) => [key.toBigInt(), value])); } @@ -52,29 +56,20 @@ export class TokenCoinData implements ISerializable { /** Parse from a JSON representation. */ public static fromJSON(data: unknown): TokenCoinData { - if (!Array.isArray(data)) { + if ( + !Array.isArray(data) || + !data.every( + (value) => + Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && typeof value[1] === 'string', + ) + ) { throw new Error('Invalid coin data JSON format'); } const coins: [bigint, bigint][] = []; - // Helper function to safely parse values that might have been corrupted by JSON.stringify() - const parseValue = (v: unknown): bigint => { - if (typeof v === 'bigint') return v; - if (typeof v === 'string' || typeof v === 'number') return BigInt(v); - if (v === null) { - throw new Error(`Cannot convert null to BigInt. This indicates a JSON serialization issue with BigInt values.`); - } - if (typeof v === 'object') { - throw new Error( - `Cannot convert object to BigInt. This indicates a JSON serialization issue with BigInt values. Received: ${JSON.stringify(v)}`, - ); - } - throw new Error(`Unsupported type for BigInt conversion: ${typeof v}. Expected string, number, or bigint.`); - }; - for (const [key, value] of data) { - coins.push([parseValue(key), parseValue(value)]); + coins.push([BigInt(key), BigInt(value)]); } return new TokenCoinData(coins); diff --git a/src/transaction/OfflineTransaction.ts b/src/transaction/OfflineTransaction.ts index 034a35dd..492ce979 100644 --- a/src/transaction/OfflineTransaction.ts +++ b/src/transaction/OfflineTransaction.ts @@ -14,7 +14,6 @@ import { TokenJsonDeserializer } from '../serializer/token/TokenJsonDeserializer import { ITokenJson, Token } from '../token/Token.js'; import { TokenFactory } from '../token/TokenFactory.js'; import { TokenState } from '../token/TokenState.js'; -import { JsonUtils } from '../utils/JsonUtils.js'; /** JSON representation of an {@link OfflineTransaction}. */ export interface IOfflineTransactionJson { @@ -80,18 +79,6 @@ export class OfflineTransaction implements ISerializable { return new OfflineTransaction(offlineCommitment, token); } - /** - * Create OfflineTransaction from JSON string. - * This method can handle JSON strings that were created with toJSONString(). - * - * @param jsonString JSON string representation - * @returns Promise - */ - public static fromJSONString(jsonString: string): Promise { - const parsed = JsonUtils.parse(jsonString); - return OfflineTransaction.fromJSON(parsed); - } - /** * Type guard to check if data is valid OfflineTransaction JSON. * @param data Data to validate @@ -130,18 +117,4 @@ export class OfflineTransaction implements ISerializable { token: this.token.toJSON(), }; } - - /** - * Serialize to JSON string with BigInt support. - * This method handles potential BigInt values that might exist in the object graph - * and converts them to strings to prevent JSON serialization errors. - * - * Use this method when you need to serialize for actual transfer (e.g., NFC, file, etc.). - * - * @param space Optional spacing for formatting - * @returns JSON string with BigInt values safely converted - */ - public toJSONString(space?: string | number): string { - return JsonUtils.safeStringify(this, space); - } } diff --git a/src/utils/JsonUtils.ts b/src/utils/JsonUtils.ts deleted file mode 100644 index 5c3d799d..00000000 --- a/src/utils/JsonUtils.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Utility functions for JSON serialization with BigInt support. - */ -export class JsonUtils { - /** - * JSON.stringify with BigInt support. - * Converts BigInt values to strings automatically. - * - * @param value The value to stringify - * @param space Optional spacing for formatting - * @returns JSON string with BigInt values converted to strings - */ - public static stringify(value: unknown, space?: string | number): string { - return JSON.stringify( - value, - (key, val) => { - if (typeof val === 'bigint') { - return val.toString(); - } - return val; - }, - space, - ); - } - - /** - * JSON.parse that can handle BigInt values that were stringified. - * This is a basic parser - for complex BigInt restoration, - * use the specific fromJSON methods of each class. - * - * @param text The JSON string to parse - * @returns Parsed object - */ - public static parse(text: string): unknown { - return JSON.parse(text); - } - - /** - * Safe serialization for objects that might contain BigInt values. - * First calls toJSON() if available, then applies BigInt-safe stringify. - * - * @param obj Object to serialize - * @param space Optional spacing for formatting - * @returns JSON string - */ - public static safeStringify(obj: unknown, space?: string | number): string { - // If object has toJSON method, use it first - if (obj && typeof obj === 'object' && 'toJSON' in obj && typeof obj.toJSON === 'function') { - const jsonObj = obj.toJSON(); - return JsonUtils.stringify(jsonObj, space); - } - - // Otherwise use BigInt-safe stringify directly - return JsonUtils.stringify(obj, space); - } -} diff --git a/tests/unit/token/fungible/BigIntSerializationTest.ts b/tests/unit/token/fungible/BigIntSerializationTest.ts deleted file mode 100644 index 23ed38c0..00000000 --- a/tests/unit/token/fungible/BigIntSerializationTest.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { TokenCoinData } from '../../../../src/token/fungible/TokenCoinData.js'; - -describe('BigInt JSON Serialization Investigation', () => { - it('should investigate raw BigInt values in JSON output', () => { - // Create a simple TokenCoinData with BigInt values - const coinData = new TokenCoinData([[123n, 456n]]); - - // Test the toJSON method directly - const tokenCoinJson = coinData.toJSON(); - console.log('TokenCoinData.toJSON():', tokenCoinJson); - console.log('Type of first key:', typeof tokenCoinJson[0][0]); - console.log('Type of first value:', typeof tokenCoinJson[0][1]); - - // Test what happens when we JSON.stringify the result - const stringified = JSON.stringify(tokenCoinJson); - console.log('JSON.stringify(tokenCoinJson):', stringified); - - // Parse it back and see what we get - const parsed = JSON.parse(stringified); - console.log('JSON.parse(stringified):', parsed); - console.log('Type after parse - key:', typeof parsed[0][0]); - console.log('Type after parse - value:', typeof parsed[0][1]); - - // Ensure TokenCoinData.toJSON() returns strings, not BigInt - expect(typeof tokenCoinJson[0][0]).toBe('string'); - expect(typeof tokenCoinJson[0][1]).toBe('string'); - }); - - it('should test if objects containing BigInt get corrupted', () => { - // Create an object that might contain BigInt somewhere - const objWithBigInt = { - array: [456n, 'string'], - bigIntValue: 123n, - nested: { - innerBigInt: 789n, - }, - normalString: 'test', - }; - - console.log('Original object:', objWithBigInt); - - try { - const stringified = JSON.stringify(objWithBigInt); - console.log('JSON.stringify result:', stringified); - - const parsed = JSON.parse(stringified); - console.log('Parsed back:', parsed); - } catch (error) { - console.log('JSON.stringify error:', error.message); - } - }); - - it('should test JSON.stringify with replacer for BigInt', () => { - const objWithBigInt = { - array: [456n, 'string'], - bigIntValue: 123n, - normalString: 'test', - }; - - // Test with BigInt replacer - const stringified = JSON.stringify(objWithBigInt, (key, value) => { - if (typeof value === 'bigint') { - return value.toString(); - } - return value; - }); - - console.log('JSON.stringify with BigInt replacer:', stringified); - - const parsed = JSON.parse(stringified); - console.log('Parsed with replacer:', parsed); - }); -}); diff --git a/tests/unit/token/fungible/TokenCoinDataBigIntTest.ts b/tests/unit/token/fungible/TokenCoinDataBigIntTest.ts deleted file mode 100644 index 5589a5da..00000000 --- a/tests/unit/token/fungible/TokenCoinDataBigIntTest.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { TokenCoinData } from '../../../../src/token/fungible/TokenCoinData.js'; - -describe('TokenCoinData BigInt JSON Serialization', () => { - it('should handle normal JSON serialization correctly', () => { - const coinData = new TokenCoinData([ - [123n, 456n], - [789n, 101112n], - ]); - const json = coinData.toJSON(); - const restored = TokenCoinData.fromJSON(json); - - expect(restored.getByKey(123n)).toBe(456n); - expect(restored.getByKey(789n)).toBe(101112n); - }); - - it('should provide clear error message for object types from JSON.stringify BigInt corruption', () => { - // Simulate what happens when JSON.stringify converts BigInt to {} - const corruptedData = [{}, {}]; - - expect(() => { - TokenCoinData.fromJSON([corruptedData]); - }).toThrow( - 'Cannot convert object to BigInt. This indicates a JSON serialization issue with BigInt values. Received: {}', - ); - }); - - it('should handle string values correctly', () => { - const stringData = [ - ['123', '456'], - ['789', '101112'], - ]; - const restored = TokenCoinData.fromJSON(stringData); - - expect(restored.getByKey(123n)).toBe(456n); - expect(restored.getByKey(789n)).toBe(101112n); - }); - - it('should handle number values correctly', () => { - const numberData = [ - [123, 456], - [789, 101112], - ]; - const restored = TokenCoinData.fromJSON(numberData); - - expect(restored.getByKey(123n)).toBe(456n); - expect(restored.getByKey(789n)).toBe(101112n); - }); - - it('should handle BigInt values correctly', () => { - const bigintData = [ - [123n, 456n], - [789n, 101112n], - ]; - const restored = TokenCoinData.fromJSON(bigintData); - - expect(restored.getByKey(123n)).toBe(456n); - expect(restored.getByKey(789n)).toBe(101112n); - }); - - it('should provide helpful error for undefined values', () => { - const undefinedData = [[undefined, '456']]; - - expect(() => { - TokenCoinData.fromJSON(undefinedData); - }).toThrow('Unsupported type for BigInt conversion: undefined. Expected string, number, or bigint.'); - }); - - it('should provide helpful error for null values', () => { - const nullData = [[null, '456']]; - - expect(() => { - TokenCoinData.fromJSON(nullData); - }).toThrow('Cannot convert null to BigInt. This indicates a JSON serialization issue with BigInt values.'); - }); - - it('should handle mixed valid types', () => { - const mixedData = [ - ['123', 456], - [789n, '101112'], - ]; - const restored = TokenCoinData.fromJSON(mixedData); - - expect(restored.getByKey(123n)).toBe(456n); - expect(restored.getByKey(789n)).toBe(101112n); - }); -}); diff --git a/tests/unit/token/fungible/TokenCoinDataTest.ts b/tests/unit/token/fungible/TokenCoinDataTest.ts new file mode 100644 index 00000000..53e66348 --- /dev/null +++ b/tests/unit/token/fungible/TokenCoinDataTest.ts @@ -0,0 +1,9 @@ +import { TokenCoinData } from '../../../../src/token/fungible/TokenCoinData.js'; + +describe('TokenCoinData', () => { + it('should check if json input is correct', () => { + expect(() => TokenCoinData.fromJSON([[123n, 456n]])).toThrow('Invalid coin data JSON format'); + expect(TokenCoinData.fromJSON([['123', '456']])).toEqual({ _coins: new Map([[123n, 456n]]) }); + expect(TokenCoinData.fromJSON([['123', '456']]).toJSON()).toEqual([['123', '456']]); + }); +}); diff --git a/tests/unit/transaction/OfflineTransactionBigIntFixTest.ts b/tests/unit/transaction/OfflineTransactionBigIntFixTest.ts deleted file mode 100644 index d2660f0f..00000000 --- a/tests/unit/transaction/OfflineTransactionBigIntFixTest.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { JsonUtils } from '../../../src/utils/JsonUtils.js'; - -describe('OfflineTransaction BigInt Serialization Fix', () => { - it('should demonstrate JsonUtils.safeStringify handles BigInt correctly', () => { - // Create an object that would fail with normal JSON.stringify - const objWithBigInt = { - arrayWithBigInt: [111n, 'string', 222n], - bigIntField: 123456789012345678901234567890n, - nestedObject: { - anotherBigInt: 987654321098765432109876543210n, - normalValue: 'nested', - }, - normalField: 'test', - }; - - // Test that normal JSON.stringify fails - expect(() => { - JSON.stringify(objWithBigInt); - }).toThrow('Do not know how to serialize a BigInt'); - - // Test that JsonUtils.safeStringify works - const result = JsonUtils.safeStringify(objWithBigInt); - expect(typeof result).toBe('string'); - - // Parse back and verify BigInt values became strings - const parsed = JSON.parse(result); - expect(parsed.bigIntField).toBe('123456789012345678901234567890'); - expect(parsed.nestedObject.anotherBigInt).toBe('987654321098765432109876543210'); - expect(parsed.arrayWithBigInt).toEqual(['111', 'string', '222']); - }); - - it('should document the proper usage pattern for OfflineTransaction', () => { - // Simulate creating a minimal OfflineTransaction (without full setup) - const mockOfflineTransaction = { - toJSON(): unknown { - return { - commitment: { - authenticator: { signature: 'test-signature' }, - requestId: 'test-request-id', - transactionData: { - recipient: 'test-recipient', - salt: 'test-salt', - sourceState: { - data: null, - unlockPredicate: { type: 'test' }, - }, - }, - }, - token: { - genesis: { - data: { - coinData: [['123', '456']], // Already properly stringified - }, - }, - state: { data: null }, - transactions: [], - }, - }; - }, - }; - - // Test that toJSON() works (returns object) - const jsonObj = mockOfflineTransaction.toJSON(); - expect(typeof jsonObj).toBe('object'); - - // Test that JSON.stringify on toJSON() result works - const stringified = JSON.stringify(jsonObj); - expect(typeof stringified).toBe('string'); - - // Test that our safe stringify works on objects with toJSON - const safeStringified = JsonUtils.safeStringify(mockOfflineTransaction); - expect(typeof safeStringified).toBe('string'); - - // Both should produce the same result when the toJSON() is properly implemented - expect(stringified).toBe(safeStringified); - }); - - it('should demonstrate the BigInt serialization problem and solution', () => { - // Create an object that has toJSON but still contains raw BigInt somewhere - const problematicObject = { - toJSON(): unknown { - return { - // This simulates a case where toJSON returns an object that still contains BigInt - coinData: new Map([[123n, 456n]]), // Map with BigInt keys/values - normalField: 'test', - }; - }, - }; - - // Test that normal JSON.stringify on toJSON() result handles Map (converts to {}) - const jsonResult = problematicObject.toJSON(); - const mapStringified = JSON.stringify(jsonResult); - // Map gets serialized as {}, demonstrating potential data loss - expect(mapStringified).toContain('{}'); - - // But JsonUtils.safeStringify handles it by processing the whole object - const safeResult = JsonUtils.safeStringify(problematicObject); - expect(typeof safeResult).toBe('string'); - - // The result converts Map to an object and BigInt values to strings - const parsed = JSON.parse(safeResult); - expect(parsed.normalField).toBe('test'); - // Map gets converted to an empty object by JSON.stringify, but BigInt handling is demonstrated - }); - - it('should show usage recommendations', () => { - console.log(` -USAGE RECOMMENDATIONS FOR OFFLINE TRANSACTION SERIALIZATION: - -1. For normal SDK usage (returning objects): - const jsonObj = offlineTransaction.toJSON(); - -2. For safe serialization that might contain BigInt: - const jsonString = offlineTransaction.toJSONString(); - -3. For manual safe serialization: - const jsonString = JsonUtils.safeStringify(offlineTransaction); - -4. For deserialization: - const restored = await OfflineTransaction.fromJSONString(jsonString); - // or - const restored = await OfflineTransaction.fromJSON(JsonUtils.parse(jsonString)); - -The new toJSONString() method ensures BigInt values are converted to strings -before JSON serialization, preventing the "Do not know how to serialize a BigInt" error. - `); - - // This test always passes - it's just for documentation - expect(true).toBe(true); - }); -}); diff --git a/tests/unit/utils/JsonUtilsTest.ts b/tests/unit/utils/JsonUtilsTest.ts deleted file mode 100644 index 5cf6b32c..00000000 --- a/tests/unit/utils/JsonUtilsTest.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { JsonUtils } from '../../../src/utils/JsonUtils.js'; - -describe('JsonUtils', () => { - it('should handle BigInt values in stringify', () => { - const objWithBigInt = { - array: [456n, 'string'], - bigIntValue: 123n, - nested: { - innerBigInt: 789n, - }, - normalString: 'test', - }; - - const result = JsonUtils.stringify(objWithBigInt); - expect(result).toBe( - '{"array":["456","string"],"bigIntValue":"123","nested":{"innerBigInt":"789"},"normalString":"test"}', - ); - - const parsed = JsonUtils.parse(result); - expect(parsed).toEqual({ - array: ['456', 'string'], - bigIntValue: '123', - nested: { - innerBigInt: '789', - }, - normalString: 'test', - }); - }); - - it('should handle objects with toJSON method', () => { - class TestClass { - public constructor(private value: bigint) {} - - public toJSON(): unknown { - return { - type: 'TestClass', - value: this.value.toString(), - }; - } - } - - const obj = new TestClass(123n); - const result = JsonUtils.safeStringify(obj); - - expect(result).toBe('{"type":"TestClass","value":"123"}'); - }); - - it('should handle arrays with BigInt values', () => { - const arr = [123n, 'string', { nested: 456n }]; - const result = JsonUtils.stringify(arr); - - expect(result).toBe('["123","string",{"nested":"456"}]'); - }); - - it('should handle null and undefined values', () => { - const obj = { - bigIntValue: 123n, - nullValue: null, - undefinedValue: undefined, - }; - - const result = JsonUtils.stringify(obj); - expect(result).toBe('{"bigIntValue":"123","nullValue":null}'); - }); -}); From 062e38c6c1f5060dfcb3007c43f776ba728a7fa2 Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Fri, 27 Jun 2025 16:30:06 +0300 Subject: [PATCH 02/10] Update commons library --- package-lock.json | 8 ++++---- package.json | 2 +- src/StateTransitionClient.ts | 4 ++-- src/predicate/DefaultPredicate.ts | 2 +- src/token/TokenFactory.ts | 4 ++-- src/token/TokenId.ts | 7 ++++--- src/token/fungible/CoinId.ts | 8 +++++--- src/token/fungible/TokenCoinData.ts | 6 +++--- src/transaction/TokenSplitBuilder.ts | 12 ++++++------ src/utils/InclusionProofUtils.ts | 2 +- tests/token/CommonTestFlow.ts | 29 ++++++++++------------------ tests/unit/TestAggregatorClient.ts | 6 +++--- 12 files changed, 42 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2cd87d4e..30984bf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.4.7", "license": "ISC", "dependencies": { - "@unicitylabs/commons": "2.4.0-rc.d7fa010" + "@unicitylabs/commons": "2.4.0-rc.24d6a7c" }, "devDependencies": { "@babel/preset-env": "7.27.2", @@ -3376,9 +3376,9 @@ "license": "ISC" }, "node_modules/@unicitylabs/commons": { - "version": "2.4.0-rc.d7fa010", - "resolved": "https://registry.npmjs.org/@unicitylabs/commons/-/commons-2.4.0-rc.d7fa010.tgz", - "integrity": "sha512-fFM/op8BGZ+cty7KMrUSmhgpUnVy/lwh+pi9TH6ZTZbwgPdGjfjGB7guH6CHSmVrJ0/eZh25TdF5II6TW/Xl1A==", + "version": "2.4.0-rc.24d6a7c", + "resolved": "https://registry.npmjs.org/@unicitylabs/commons/-/commons-2.4.0-rc.24d6a7c.tgz", + "integrity": "sha512-GsiibpCSjccEein9C4BaCRhh0ViXhYLXQ9z9iGRGA0CxTw9m4V3e9wKWr1ewXjSDxB/omZPqiGMnalKuBe8jjw==", "license": "ISC", "dependencies": { "@noble/curves": "1.9.1", diff --git a/package.json b/package.json index 893bc4de..b137f5c4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "license": "ISC", "homepage": "https://unicitynetwork.github.io/state-transition-sdk/", "dependencies": { - "@unicitylabs/commons": "2.4.0-rc.d7fa010" + "@unicitylabs/commons": "2.4.0-rc.24d6a7c" }, "devDependencies": { "@babel/preset-env": "7.27.2", diff --git a/src/StateTransitionClient.ts b/src/StateTransitionClient.ts index bf814c53..1a2a68d1 100644 --- a/src/StateTransitionClient.ts +++ b/src/StateTransitionClient.ts @@ -104,7 +104,7 @@ export class StateTransitionClient { { requestId, transactionData }: Commitment, inclusionProof: InclusionProof, ): Promise> { - const status = await inclusionProof.verify(requestId.toBigInt()); + const status = await inclusionProof.verify(requestId); if (status != InclusionProofVerificationStatus.OK) { throw new Error('Inclusion proof verification failed.'); } @@ -180,7 +180,7 @@ export class StateTransitionClient { const requestId = await RequestId.create(publicKey, token.state.hash); const inclusionProof = await this.client.getInclusionProof(requestId); // TODO: Check ownership? - return inclusionProof.verify(requestId.toBigInt()); + return inclusionProof.verify(requestId); } /** diff --git a/src/predicate/DefaultPredicate.ts b/src/predicate/DefaultPredicate.ts index 94c4f4c5..2e2bc13a 100644 --- a/src/predicate/DefaultPredicate.ts +++ b/src/predicate/DefaultPredicate.ts @@ -129,7 +129,7 @@ export abstract class DefaultPredicate implements IPredicate { // Verify inclusion proof path. const requestId = await RequestId.create(this.publicKey, transaction.data.sourceState.hash); - const status = await transaction.inclusionProof.verify(requestId.toBigInt()); + const status = await transaction.inclusionProof.verify(requestId); return status === InclusionProofVerificationStatus.OK; } diff --git a/src/token/TokenFactory.ts b/src/token/TokenFactory.ts index 243a82c5..082ca587 100644 --- a/src/token/TokenFactory.ts +++ b/src/token/TokenFactory.ts @@ -115,7 +115,7 @@ export class TokenFactory { return false; } - const coinPathResult = await proof.coinTreePath.verify(transaction.data.tokenId.toBigInt()); + const coinPathResult = await proof.coinTreePath.verify(transaction.data.tokenId.toBitString().toBigInt()); if (!coinPathResult.result) { return false; } @@ -139,7 +139,7 @@ export class TokenFactory { // Verify inclusion proof path. const requestId = await RequestId.create(signingService.publicKey, transaction.data.sourceState.hash); - const status = await transaction.inclusionProof.verify(requestId.toBigInt()); + const status = await transaction.inclusionProof.verify(requestId); return status === InclusionProofVerificationStatus.OK; } } diff --git a/src/token/TokenId.ts b/src/token/TokenId.ts index a939028f..21283904 100644 --- a/src/token/TokenId.ts +++ b/src/token/TokenId.ts @@ -1,4 +1,5 @@ import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; +import { BitString } from '@unicitylabs/commons/lib/util/BitString.js'; import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; /** @@ -37,9 +38,9 @@ export class TokenId { } /** - * Converts the TokenId to a BigInt representation. + * Converts the TokenId to a bitstring representation. */ - public toBigInt(): bigint { - return BigInt(`0x01${HexConverter.encode(this.toCBOR())}`); + public toBitString(): BitString { + return new BitString(this.toCBOR()); } } diff --git a/src/token/fungible/CoinId.ts b/src/token/fungible/CoinId.ts index bc3dbedc..c54ddf03 100644 --- a/src/token/fungible/CoinId.ts +++ b/src/token/fungible/CoinId.ts @@ -1,5 +1,7 @@ import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; +import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; +import { BitString } from '@unicitylabs/commons/lib/util/BitString.js'; import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; /** Identifier for a fungible coin type. */ @@ -32,7 +34,7 @@ export class CoinId { * @param value bigint represantation of coin id */ public static fromBigInt(value: bigint): CoinId { - return CoinId.fromCBOR(HexConverter.decode(value.toString(16).slice(1))); + return CoinId.fromCBOR(BigintConverter.encode(value).slice(1)); } /** Hex string representation. */ @@ -48,7 +50,7 @@ export class CoinId { /** * Converts the CoinId to a BigInt representation. */ - public toBigInt(): bigint { - return BigInt(`0x01${HexConverter.encode(this.toCBOR())}`); + public toBitString(): BitString { + return new BitString(this.toCBOR()); } } diff --git a/src/token/fungible/TokenCoinData.ts b/src/token/fungible/TokenCoinData.ts index df2ea571..2de11a25 100644 --- a/src/token/fungible/TokenCoinData.ts +++ b/src/token/fungible/TokenCoinData.ts @@ -18,7 +18,7 @@ export class TokenCoinData implements ISerializable { /** * @param coins Array of coin id serialized to bigint and balance pairs */ - public constructor(coins: [bigint, bigint][]) { + private constructor(coins: [bigint, bigint][]) { this._coins = new Map(coins); } @@ -36,7 +36,7 @@ export class TokenCoinData implements ISerializable { * @param coins Array of tuples of CoinId and bigint. */ public static create(coins: [CoinId, bigint][]): TokenCoinData { - return new TokenCoinData(coins.map(([key, value]) => [key.toBigInt(), value])); + return new TokenCoinData(coins.map(([key, value]) => [key.toBitString().toBigInt(), value])); } /** Create a coin data object from CBOR. */ @@ -77,7 +77,7 @@ export class TokenCoinData implements ISerializable { /** Get the balance of a specific coin. */ public get(coinId: CoinId): bigint | undefined { - return this._coins.get(coinId.toBigInt()); + return this._coins.get(coinId.toBitString().toBigInt()); } /** Get the balance of a coin by its internal map key. */ diff --git a/src/transaction/TokenSplitBuilder.ts b/src/transaction/TokenSplitBuilder.ts index c96f9ead..5263fc3f 100644 --- a/src/transaction/TokenSplitBuilder.ts +++ b/src/transaction/TokenSplitBuilder.ts @@ -52,7 +52,7 @@ class SplitToken { throw new Error('Amount must be greater than zero'); } - this._coins.set(coinId.toBigInt(), amount); + this._coins.set(coinId.toBitString().toBigInt(), amount); return this; } } @@ -80,7 +80,7 @@ class TokenSplitData { const transactions: MintTransactionData[] = []; for (const [tokenId, splitToken] of this.tokens.entries()) { - const coinData: [bigint, bigint][] = []; + const coinData: [CoinId, bigint][] = []; const proofs = new Map(); for (const [coinId, amount] of splitToken.coins.entries()) { proofs.set( @@ -90,7 +90,7 @@ class TokenSplitData { this._coinTrees.get(coinId)?.getPath(tokenId) as MerkleSumTreePath, ), ); - coinData.push([coinId, amount]); + coinData.push([CoinId.fromBigInt(coinId), amount]); } transactions.push( @@ -98,7 +98,7 @@ class TokenSplitData { splitToken.tokenId, splitToken.tokenType, splitToken.data, - new TokenCoinData(coinData), + TokenCoinData.create(coinData), splitToken.recipient, splitToken.salt, splitToken.dataHash, @@ -122,12 +122,12 @@ export class TokenSplitBuilder { dataHash: DataHash, salt: Uint8Array, ): SplitToken { - if (this.tokens.has(id.toBigInt())) { + if (this.tokens.has(id.toBitString().toBigInt())) { throw new Error('Token already exists in split request'); } const builder = new SplitToken(id, type, data, recipient, dataHash, salt); - this.tokens.set(id.toBigInt(), builder); + this.tokens.set(id.toBitString().toBigInt(), builder); return builder; } diff --git a/src/utils/InclusionProofUtils.ts b/src/utils/InclusionProofUtils.ts index c0880b64..f6a0947b 100644 --- a/src/utils/InclusionProofUtils.ts +++ b/src/utils/InclusionProofUtils.ts @@ -37,7 +37,7 @@ export async function waitInclusionProof( while (true) { try { const inclusionProof = await client.getInclusionProof(commitment); - if ((await inclusionProof.verify(commitment.requestId.toBigInt())) === InclusionProofVerificationStatus.OK) { + if ((await inclusionProof.verify(commitment.requestId)) === InclusionProofVerificationStatus.OK) { return inclusionProof; } } catch (err) { diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index bbcd4cbb..7ccafb54 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -187,23 +187,13 @@ export async function testOfflineTransferFlow(client: StateTransitionClient): Pr const offlineTransaction = new OfflineTransaction(offlineCommitment, token); // Test the full JSON serialization cycle that would happen in real usage - // 1. Get JSON object - const offlineTxJson = offlineTransaction.toJSON(); - - // 2. Simulate actual JSON string transfer (this should expose any BigInt issues) - let jsonString: string; - try { - jsonString = JSON.stringify(offlineTxJson); - } catch (error) { - // If JSON.stringify fails due to BigInt, use the safe method - console.warn('JSON.stringify failed, using safe serialization:', error.message); - jsonString = offlineTransaction.toJSONString(); - } + // 1. Get JSON representation of the offline transaction + const offlineTxJson = JSON.stringify(offlineTransaction.toJSON()); - // 3. Simulate transfer and parsing (what recipient would do) - const parsedJson = JSON.parse(jsonString); + // 2. Simulate transfer and parsing (what recipient would do) + const parsedJson = JSON.parse(offlineTxJson); - // 4. Deserialize back to object + // 3. Deserialize back to object //...sender sends the "package" offline to the recipient offlineTxPackage = await OfflineTransaction.fromJSON(parsedJson); } @@ -411,7 +401,7 @@ async function splitToken( const signingService = await SigningService.createFromSecret(ownerSecret, nonce); const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); - predicates.set(tokenId.toBigInt(), predicate); + predicates.set(tokenId.toBitString().toBigInt(), predicate); const token = builder.createToken( tokenId, @@ -456,9 +446,10 @@ async function splitToken( const mintTransactions = await splitData.toMintTransactionDataList(updatedToken); return Promise.all( mintTransactions.map((data) => - TokenState.create(predicates.get(data.tokenId.toBigInt())!, textEncoder.encode(customDataString)).then((state) => - createMintToken(client, data, state), - ), + TokenState.create( + predicates.get(data.tokenId.toBitString().toBigInt())!, + textEncoder.encode(customDataString), + ).then((state) => createMintToken(client, data, state)), ), ); } diff --git a/tests/unit/TestAggregatorClient.ts b/tests/unit/TestAggregatorClient.ts index b744bd01..5d0673cc 100644 --- a/tests/unit/TestAggregatorClient.ts +++ b/tests/unit/TestAggregatorClient.ts @@ -28,7 +28,7 @@ export class TestAggregatorClient implements IAggregatorClient { transactionHash: DataHash, authenticator: Authenticator, ): Promise { - const path = requestId.toBigInt(); + const path = requestId.toBitString().toBigInt(); const transaction = new Transaction(authenticator, transactionHash); const leafValue = await LeafValue.create(authenticator, transactionHash); this.smt.addLeaf(path, leafValue.bytes); @@ -38,11 +38,11 @@ export class TestAggregatorClient implements IAggregatorClient { } public async getInclusionProof(requestId: RequestId): Promise { - const transaction = this.requests.get(requestId.toBigInt()); + const transaction = this.requests.get(requestId.toBitString().toBigInt()); const root = await this.smt.calculateRoot(); return Promise.resolve( new InclusionProof( - root.getPath(requestId.toBigInt()), + root.getPath(requestId.toBitString().toBigInt()), transaction?.authenticator ?? null, transaction?.transactionHash ?? null, ), From ae962d9fe21ca427c4c5d91f15e2908fe7520c21 Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Fri, 27 Jun 2025 16:32:04 +0300 Subject: [PATCH 03/10] Update CoinId toBitString jsdoc --- src/token/fungible/CoinId.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/token/fungible/CoinId.ts b/src/token/fungible/CoinId.ts index c54ddf03..d3682db4 100644 --- a/src/token/fungible/CoinId.ts +++ b/src/token/fungible/CoinId.ts @@ -48,7 +48,7 @@ export class CoinId { } /** - * Converts the CoinId to a BigInt representation. + * Converts the CoinId to a bitstring representation. */ public toBitString(): BitString { return new BitString(this.toCBOR()); From 8867179748ffc58f0a77bcb644b84fed603bb276 Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Fri, 27 Jun 2025 18:49:30 +0300 Subject: [PATCH 04/10] Move split builder to separate files --- src/token/TokenFactory.ts | 8 +- src/token/fungible/CoinId.ts | 9 - src/token/fungible/TokenCoinData.ts | 58 +++---- src/transaction/TokenSplitBuilder.ts | 157 ------------------ src/transaction/split/SplitResult.ts | 73 ++++++++ src/transaction/split/SplitToken.ts | 31 ++++ src/transaction/split/SplitTokenBuilder.ts | 51 ++++++ src/transaction/split/TokenSplitBuilder.ts | 64 +++++++ tests/token/CommonTestFlow.ts | 4 +- .../unit/token/fungible/TokenCoinDataTest.ts | 4 +- 10 files changed, 248 insertions(+), 211 deletions(-) delete mode 100644 src/transaction/TokenSplitBuilder.ts create mode 100644 src/transaction/split/SplitResult.ts create mode 100644 src/transaction/split/SplitToken.ts create mode 100644 src/transaction/split/SplitTokenBuilder.ts create mode 100644 src/transaction/split/TokenSplitBuilder.ts diff --git a/src/token/TokenFactory.ts b/src/token/TokenFactory.ts index 082ca587..d2af5257 100644 --- a/src/token/TokenFactory.ts +++ b/src/token/TokenFactory.ts @@ -105,7 +105,11 @@ export class TokenFactory { return false; } - if (transaction.data.coinData.size !== reason.proofs.size) { + const coins = new Map( + transaction.data.coinData?.coins.map(([id, value]) => [id.toBitString().toBigInt(), value]) ?? [], + ); + + if (coins?.size !== reason.proofs.size) { return false; } @@ -126,7 +130,7 @@ export class TokenFactory { } const sumPathLeaf = proof.coinTreePath.steps.at(0)?.branch?.sum; - if (transaction.data.coinData?.getByKey(coinId) !== sumPathLeaf) { + if (coins.get(coinId) !== sumPathLeaf) { return false; } diff --git a/src/token/fungible/CoinId.ts b/src/token/fungible/CoinId.ts index d3682db4..457be1db 100644 --- a/src/token/fungible/CoinId.ts +++ b/src/token/fungible/CoinId.ts @@ -1,6 +1,5 @@ import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; -import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; import { BitString } from '@unicitylabs/commons/lib/util/BitString.js'; import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; @@ -29,14 +28,6 @@ export class CoinId { return new CoinId(CborDecoder.readByteString(data)); } - /** - * Creates a CoinId from a bigint. - * @param value bigint represantation of coin id - */ - public static fromBigInt(value: bigint): CoinId { - return CoinId.fromCBOR(BigintConverter.encode(value).slice(1)); - } - /** Hex string representation. */ public toJSON(): string { return HexConverter.encode(this.data); diff --git a/src/token/fungible/TokenCoinData.ts b/src/token/fungible/TokenCoinData.ts index 2de11a25..0ea087d1 100644 --- a/src/token/fungible/TokenCoinData.ts +++ b/src/token/fungible/TokenCoinData.ts @@ -1,6 +1,7 @@ import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; +import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; import { CoinId } from './CoinId.js'; @@ -13,42 +14,37 @@ export type TokenCoinDataJson = [string, string][]; * Container for fungible coin balances attached to a token. */ export class TokenCoinData implements ISerializable { - private readonly _coins: Map; - /** - * @param coins Array of coin id serialized to bigint and balance pairs + * @param _coins Map of coin id bytes hex and their balances. */ - private constructor(coins: [bigint, bigint][]) { - this._coins = new Map(coins); - } - - /** Get total number of different coins */ - public get size(): number { - return this._coins.size; - } + private constructor(private readonly _coins: Map) {} - public get coins(): Map { - return new Map(Array.from(this._coins.entries()).map(([key, value]) => [CoinId.fromBigInt(key), value])); + public get coins(): [CoinId, bigint][] { + return Array.from(this._coins.entries()).map(([key, value]) => [CoinId.fromJSON(key), value]); } /** * Create a new coin data object from an array of coin id and balance pairs. - * @param coins Array of tuples of CoinId and bigint. + * @param data Array of tuples of CoinId and bigint. */ - public static create(coins: [CoinId, bigint][]): TokenCoinData { - return new TokenCoinData(coins.map(([key, value]) => [key.toBitString().toBigInt(), value])); + public static create(data: [CoinId, bigint][]): TokenCoinData { + const coins = new Map(); + for (const [coinId, balance] of data) { + coins.set(coinId.toJSON(), balance); + } + return new TokenCoinData(coins); } /** Create a coin data object from CBOR. */ public static fromCBOR(data: Uint8Array): TokenCoinData { - const coins: [bigint, bigint][] = []; + const coins = new Map(); const entries = CborDecoder.readArray(data); for (const item of entries) { const [key, value] = CborDecoder.readArray(item); - coins.push([ - BigintConverter.decode(CborDecoder.readByteString(key)), + coins.set( + HexConverter.encode(CborDecoder.readByteString(key)), BigintConverter.decode(CborDecoder.readByteString(value)), - ]); + ); } return new TokenCoinData(coins); @@ -66,23 +62,7 @@ export class TokenCoinData implements ISerializable { throw new Error('Invalid coin data JSON format'); } - const coins: [bigint, bigint][] = []; - - for (const [key, value] of data) { - coins.push([BigInt(key), BigInt(value)]); - } - - return new TokenCoinData(coins); - } - - /** Get the balance of a specific coin. */ - public get(coinId: CoinId): bigint | undefined { - return this._coins.get(coinId.toBitString().toBigInt()); - } - - /** Get the balance of a coin by its internal map key. */ - public getByKey(coinId: bigint): bigint | undefined { - return this._coins.get(coinId); + return new TokenCoinData(new Map(data.map(([key, value]) => [key, BigInt(value)]))); } /** @inheritDoc */ @@ -90,7 +70,7 @@ export class TokenCoinData implements ISerializable { return CborEncoder.encodeArray( Array.from(this._coins.entries()).map(([key, value]) => CborEncoder.encodeArray([ - CborEncoder.encodeByteString(BigintConverter.encode(key)), + CborEncoder.encodeByteString(HexConverter.decode(key)), CborEncoder.encodeByteString(BigintConverter.encode(value)), ]), ), @@ -105,7 +85,7 @@ export class TokenCoinData implements ISerializable { /** Convert instance to readable string */ public toString(): string { return dedent` - FungibleTokenData + TokenCoinData: ${Array.from(this._coins.entries()) .map(([key, value]) => `${key}: ${value}`) .join('\n')}`; diff --git a/src/transaction/TokenSplitBuilder.ts b/src/transaction/TokenSplitBuilder.ts deleted file mode 100644 index 5263fc3f..00000000 --- a/src/transaction/TokenSplitBuilder.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; -import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; -import type { IDataHasher } from '@unicitylabs/commons/lib/hash/IDataHasher.js'; -import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; -import { MerkleSumTreeRootNode } from '@unicitylabs/commons/lib/smst/MerkleSumTreeRootNode.js'; -import { SparseMerkleSumTreeBuilder } from '@unicitylabs/commons/lib/smst/SparseMerkleSumTreeBuilder.js'; -import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; -import { MerkleTreeRootNode } from '@unicitylabs/commons/lib/smt/MerkleTreeRootNode.js'; -import { SparseMerkleTreeBuilder } from '@unicitylabs/commons/lib/smt/SparseMerkleTreeBuilder.js'; -import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; - -import { MintTransactionData } from './MintTransactionData.js'; -import { Transaction } from './Transaction.js'; -import { ISerializable } from '../ISerializable.js'; -import { CoinId } from '../token/fungible/CoinId.js'; -import { SplitMintReason } from '../token/fungible/SplitMintReason.js'; -import { SplitMintReasonProof } from '../token/fungible/SplitMintReasonProof.js'; -import { TokenCoinData } from '../token/fungible/TokenCoinData.js'; -import { Token } from '../token/Token.js'; -import { TokenId } from '../token/TokenId.js'; -import { TokenType } from '../token/TokenType.js'; - -class SplitToken { - private readonly _coins = new Map(); - - public constructor( - public readonly tokenId: TokenId, - public readonly tokenType: TokenType, - private readonly _data: Uint8Array, - public readonly recipient: string, - public readonly dataHash: DataHash, - private readonly _salt: Uint8Array, - ) { - this._data = new Uint8Array(_data); - this._salt = new Uint8Array(_salt); - } - - public get coins(): Map { - return new Map(this._coins); - } - - public get data(): Uint8Array { - return new Uint8Array(this._data); - } - - public get salt(): Uint8Array { - return new Uint8Array(this._salt); - } - - public addCoin(coinId: CoinId, amount: bigint): this { - if (amount <= 0n) { - throw new Error('Amount must be greater than zero'); - } - - this._coins.set(coinId.toBitString().toBigInt(), amount); - return this; - } -} - -class TokenSplitData { - public constructor( - private readonly tokens: Map, - private readonly _coinTrees: Map, - private readonly _aggregationTree: MerkleTreeRootNode, - ) {} - - public get rootHash(): DataHash { - return this._aggregationTree.hash; - } - - public async toMintTransactionDataList( - token: Token>>, - ): Promise[]> { - for (const [coinId, tree] of this._coinTrees) { - const tokenAmount = token.coins?.getByKey(coinId); - if (tokenAmount !== tree.sum) { - throw new Error(`Invalid split of [${coinId}]: token contained amount ${tokenAmount}, but got ${tree.sum}`); - } - } - - const transactions: MintTransactionData[] = []; - for (const [tokenId, splitToken] of this.tokens.entries()) { - const coinData: [CoinId, bigint][] = []; - const proofs = new Map(); - for (const [coinId, amount] of splitToken.coins.entries()) { - proofs.set( - coinId, - new SplitMintReasonProof( - this._aggregationTree.getPath(coinId) as MerkleTreePath, - this._coinTrees.get(coinId)?.getPath(tokenId) as MerkleSumTreePath, - ), - ); - coinData.push([CoinId.fromBigInt(coinId), amount]); - } - - transactions.push( - await MintTransactionData.create( - splitToken.tokenId, - splitToken.tokenType, - splitToken.data, - TokenCoinData.create(coinData), - splitToken.recipient, - splitToken.salt, - splitToken.dataHash, - new SplitMintReason(token, proofs), - ), - ); - } - - return transactions; - } -} - -export class TokenSplitBuilder { - private readonly tokens = new Map(); - - public createToken( - id: TokenId, - type: TokenType, - data: Uint8Array, - recipient: string, - dataHash: DataHash, - salt: Uint8Array, - ): SplitToken { - if (this.tokens.has(id.toBitString().toBigInt())) { - throw new Error('Token already exists in split request'); - } - - const builder = new SplitToken(id, type, data, recipient, dataHash, salt); - this.tokens.set(id.toBitString().toBigInt(), builder); - return builder; - } - - public async build(factory: DataHasherFactory): Promise { - const aggregationTree = new SparseMerkleTreeBuilder(factory); - const trees = new Map(); - for (const [tokenId, token] of this.tokens.entries()) { - for (const [coinId, amount] of token.coins.entries()) { - let tree = trees.get(coinId); - if (!tree) { - tree = new SparseMerkleSumTreeBuilder(factory); - trees.set(coinId, tree); - } - tree.addLeaf(tokenId, BigintConverter.encode(coinId), amount); - } - } - - const coinTrees = new Map(); - for (const [coinId, tree] of trees.entries()) { - const root = await tree.calculateRoot(); - coinTrees.set(coinId, root); - aggregationTree.addLeaf(coinId, root.hash.imprint); - } - - return new TokenSplitData(this.tokens, coinTrees, await aggregationTree.calculateRoot()); - } -} diff --git a/src/transaction/split/SplitResult.ts b/src/transaction/split/SplitResult.ts new file mode 100644 index 00000000..fbeddabe --- /dev/null +++ b/src/transaction/split/SplitResult.ts @@ -0,0 +1,73 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; +import { MerkleSumTreeRootNode } from '@unicitylabs/commons/lib/smst/MerkleSumTreeRootNode.js'; +import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; +import { MerkleTreeRootNode } from '@unicitylabs/commons/lib/smt/MerkleTreeRootNode.js'; + +import { SplitToken } from './SplitToken.js'; +import { ISerializable } from '../../ISerializable.js'; +import { CoinId } from '../../token/fungible/CoinId.js'; +import { SplitMintReason } from '../../token/fungible/SplitMintReason.js'; +import { SplitMintReasonProof } from '../../token/fungible/SplitMintReasonProof.js'; +import { TokenCoinData } from '../../token/fungible/TokenCoinData.js'; +import { Token } from '../../token/Token.js'; +import { MintTransactionData } from '../MintTransactionData.js'; +import { Transaction } from '../Transaction.js'; + +export class SplitResult { + public constructor( + private readonly tokens: SplitToken[], + private readonly _coinTrees: Map, + private readonly _aggregationTree: MerkleTreeRootNode, + ) {} + + public get rootHash(): DataHash { + return this._aggregationTree.hash; + } + + public async toMintTransactionDataList( + token: Token>>, + ): Promise[]> { + const tokenCoins = new Map(token.coins?.coins.map(([id, value]) => [id.toJSON(), value]) ?? []); + for (const [coinId, tree] of this._coinTrees) { + const tokenAmount = tokenCoins.get(coinId); + if (tokenAmount !== tree.sum) { + throw new Error(`Invalid split of [${coinId}]: token contained amount ${tokenAmount}, but got ${tree.sum}`); + } + } + + const transactions: MintTransactionData[] = []; + for (const splitToken of this.tokens) { + const coinData: [CoinId, bigint][] = []; + const proofs = new Map(); + for (const [coinId, amount] of splitToken.coins) { + const coinIdBits = coinId.toBitString().toBigInt(); + proofs.set( + coinIdBits, + new SplitMintReasonProof( + this._aggregationTree.getPath(coinIdBits) as MerkleTreePath, + this._coinTrees + .get(coinId.toJSON()) + ?.getPath(splitToken.tokenId.toBitString().toBigInt()) as MerkleSumTreePath, + ), + ); + coinData.push([coinId, amount]); + } + + transactions.push( + await MintTransactionData.create( + splitToken.tokenId, + splitToken.tokenType, + splitToken.data, + TokenCoinData.create(coinData), + splitToken.recipient, + splitToken.salt, + splitToken.dataHash, + new SplitMintReason(token, proofs), + ), + ); + } + + return transactions; + } +} diff --git a/src/transaction/split/SplitToken.ts b/src/transaction/split/SplitToken.ts new file mode 100644 index 00000000..fb8462a5 --- /dev/null +++ b/src/transaction/split/SplitToken.ts @@ -0,0 +1,31 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; + +import { CoinId } from '../../token/fungible/CoinId.js'; +import { TokenId } from '../../token/TokenId.js'; +import { TokenType } from '../../token/TokenType.js'; + +export class SplitToken { + public constructor( + public readonly tokenId: TokenId, + public readonly tokenType: TokenType, + private readonly _data: Uint8Array, + public readonly recipient: string, + public readonly dataHash: DataHash, + private readonly _salt: Uint8Array, + private readonly _coins: Map, + ) { + this._coins = new Map(_coins); + } + + public get data(): Uint8Array { + return new Uint8Array(this._data); + } + + public get salt(): Uint8Array { + return new Uint8Array(this._salt); + } + + public get coins(): [CoinId, bigint][] { + return Array.from(this._coins.entries()).map(([key, value]) => [CoinId.fromJSON(key), value]); + } +} diff --git a/src/transaction/split/SplitTokenBuilder.ts b/src/transaction/split/SplitTokenBuilder.ts new file mode 100644 index 00000000..88da3c75 --- /dev/null +++ b/src/transaction/split/SplitTokenBuilder.ts @@ -0,0 +1,51 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; + +import { SplitToken } from './SplitToken.js'; +import { CoinId } from '../../token/fungible/CoinId.js'; +import { TokenId } from '../../token/TokenId.js'; +import { TokenType } from '../../token/TokenType.js'; + +export class SplitTokenBuilder { + private readonly _coins = new Map(); + + public constructor( + public readonly tokenId: TokenId, + public readonly tokenType: TokenType, + private readonly _data: Uint8Array, + public readonly recipient: string, + public readonly dataHash: DataHash, + private readonly _salt: Uint8Array, + ) { + this._data = new Uint8Array(_data); + this._salt = new Uint8Array(_salt); + } + + public get data(): Uint8Array { + return new Uint8Array(this._data); + } + + public get salt(): Uint8Array { + return new Uint8Array(this._salt); + } + + public addCoin(coinId: CoinId, amount: bigint): this { + if (amount <= 0n) { + throw new Error('Amount must be greater than zero'); + } + + this._coins.set(coinId.toJSON(), amount); + return this; + } + + public toSplitToken(): SplitToken { + return new SplitToken( + this.tokenId, + this.tokenType, + this._data, + this.recipient, + this.dataHash, + this._salt, + this._coins, + ); + } +} diff --git a/src/transaction/split/TokenSplitBuilder.ts b/src/transaction/split/TokenSplitBuilder.ts new file mode 100644 index 00000000..679f9281 --- /dev/null +++ b/src/transaction/split/TokenSplitBuilder.ts @@ -0,0 +1,64 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; +import type { IDataHasher } from '@unicitylabs/commons/lib/hash/IDataHasher.js'; +import { MerkleSumTreeRootNode } from '@unicitylabs/commons/lib/smst/MerkleSumTreeRootNode.js'; +import { SparseMerkleSumTreeBuilder } from '@unicitylabs/commons/lib/smst/SparseMerkleSumTreeBuilder.js'; +import { SparseMerkleTreeBuilder } from '@unicitylabs/commons/lib/smt/SparseMerkleTreeBuilder.js'; +import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; + +import { SplitResult } from './SplitResult.js'; +import { SplitToken } from './SplitToken.js'; +import { SplitTokenBuilder } from './SplitTokenBuilder.js'; +import { CoinId } from '../../token/fungible/CoinId.js'; +import { TokenId } from '../../token/TokenId.js'; +import { TokenType } from '../../token/TokenType.js'; + +export class TokenSplitBuilder { + private readonly tokens = new Map(); + + public createToken( + id: TokenId, + type: TokenType, + data: Uint8Array, + recipient: string, + dataHash: DataHash, + salt: Uint8Array, + ): SplitTokenBuilder { + const idHex = HexConverter.encode(id.bytes); + if (this.tokens.has(idHex)) { + throw new Error('Token already exists in split request'); + } + + const builder = new SplitTokenBuilder(id, type, data, recipient, dataHash, salt); + this.tokens.set(idHex, builder); + return builder; + } + + public async build(factory: DataHasherFactory): Promise { + const aggregationTree = new SparseMerkleTreeBuilder(factory); + const trees = new Map(); + const tokens: SplitToken[] = []; + for (const [, builder] of this.tokens.entries()) { + const token = builder.toSplitToken(); + for (const [coinId, amount] of token.coins) { + const treesKey = coinId.toJSON(); + let tree = trees.get(treesKey); + if (!tree) { + tree = new SparseMerkleSumTreeBuilder(factory); + trees.set(treesKey, tree); + } + tree.addLeaf(builder.tokenId.toBitString().toBigInt(), coinId.toCBOR(), amount); + } + tokens.push(token); + } + + const coinTrees = new Map(); + for (const [coinId, tree] of trees.entries()) { + const root = await tree.calculateRoot(); + coinTrees.set(coinId, root); + aggregationTree.addLeaf(CoinId.fromJSON(coinId).toBitString().toBigInt(), root.hash.imprint); + } + + return new SplitResult(tokens, coinTrees, await aggregationTree.calculateRoot()); + } +} diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index 7ccafb54..f843bcfe 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -24,7 +24,7 @@ import { TokenType } from '../../src/token/TokenType.js'; import { MintTransactionData } from '../../src/transaction/MintTransactionData.js'; import { OfflineCommitment } from '../../src/transaction/OfflineCommitment.js'; import { OfflineTransaction } from '../../src/transaction/OfflineTransaction.js'; -import { TokenSplitBuilder } from '../../src/transaction/TokenSplitBuilder.js'; +import { TokenSplitBuilder } from '../../src/transaction/split/TokenSplitBuilder.js'; import { ITransactionJson, Transaction } from '../../src/transaction/Transaction.js'; import { ITransactionDataJson, TransactionData } from '../../src/transaction/TransactionData.js'; import { waitInclusionProof } from '../../src/utils/InclusionProofUtils.js'; @@ -412,7 +412,7 @@ async function splitToken( crypto.getRandomValues(new Uint8Array(32)), ); - for (const [id, amount] of coins.coins.entries()) { + for (const [id, amount] of coins.coins) { token.addCoin(id, amount); } } diff --git a/tests/unit/token/fungible/TokenCoinDataTest.ts b/tests/unit/token/fungible/TokenCoinDataTest.ts index 53e66348..bf14ba77 100644 --- a/tests/unit/token/fungible/TokenCoinDataTest.ts +++ b/tests/unit/token/fungible/TokenCoinDataTest.ts @@ -3,7 +3,7 @@ import { TokenCoinData } from '../../../../src/token/fungible/TokenCoinData.js'; describe('TokenCoinData', () => { it('should check if json input is correct', () => { expect(() => TokenCoinData.fromJSON([[123n, 456n]])).toThrow('Invalid coin data JSON format'); - expect(TokenCoinData.fromJSON([['123', '456']])).toEqual({ _coins: new Map([[123n, 456n]]) }); - expect(TokenCoinData.fromJSON([['123', '456']]).toJSON()).toEqual([['123', '456']]); + expect(TokenCoinData.fromJSON([['0123', '456']])).toEqual({ _coins: new Map([['0123', 456n]]) }); + expect(TokenCoinData.fromJSON([['0123', '456']]).toJSON()).toEqual([['0123', '456']]); }); }); From f836315d343edcac0b75b840bcae2978db1b678a Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Mon, 30 Jun 2025 14:50:16 +0300 Subject: [PATCH 05/10] Add serializers, unify flow for transactions --- src/OfflineStateTransitionClient.ts | 63 --------- src/StateTransitionClient.ts | 58 ++++----- .../cbor/token/TokenCborSerializer.ts | 89 +++++++++++++ .../cbor/token/TokenStateCborSerializer.ts | 49 +++++++ .../transaction/CommitmentCborSerializer.ts | 63 +++++++++ .../MintTransactionCborSerializer.ts | 54 ++++++++ .../MintTransactionDataCborSerializer.ts | 94 ++++++++++++++ .../transaction/TransactionCborSerializer.ts | 62 +++++++++ .../TransactionDataCborSerializer.ts | 66 ++++++++++ .../json/token/TokenJsonSerializer.ts | 98 ++++++++++++++ .../json/token/TokenStateJsonSerializer.ts | 55 ++++++++ .../transaction/CommitmentJsonSerializer.ts | 68 ++++++++++ .../json/transaction/ITransactionJson.ts | 13 ++ .../MintTransactionDataJsonSerializer.ts | 108 ++++++++++++++++ .../MintTransactionJsonSerializer.ts | 54 ++++++++ .../TransactionDataJsonSerializer.ts | 78 ++++++++++++ .../transaction/TransactionJsonSerializer.ts | 58 +++++++++ ...kenDeserializer.ts => ITokenSerializer.ts} | 10 +- src/serializer/token/TokenCborDeserializer.ts | 56 -------- src/serializer/token/TokenJsonDeserializer.ts | 58 --------- .../MintTransactionCborDeserializer.ts | 68 ---------- .../MintTransactionJsonDeserializer.ts | 67 ---------- .../TransactionCborDeserializer.ts | 39 ------ .../TransactionJsonDeserializer.ts | 35 ----- src/token/Token.ts | 38 ++---- src/token/TokenFactory.ts | 4 +- src/token/TokenState.ts | 24 +--- src/token/fungible/SplitMintReason.ts | 3 +- src/transaction/Commitment.ts | 38 +++++- src/transaction/MintTransactionData.ts | 46 +------ src/transaction/OfflineCommitment.ts | 22 ---- src/transaction/OfflineTransaction.ts | 120 ------------------ src/transaction/Transaction.ts | 31 +---- src/transaction/TransactionData.ts | 71 ++++------- tests/MintTokenUtils.ts | 11 +- tests/token/CommonTestFlow.ts | 93 +++++++------- 36 files changed, 1173 insertions(+), 791 deletions(-) delete mode 100644 src/OfflineStateTransitionClient.ts create mode 100644 src/serializer/cbor/token/TokenCborSerializer.ts create mode 100644 src/serializer/cbor/token/TokenStateCborSerializer.ts create mode 100644 src/serializer/cbor/transaction/CommitmentCborSerializer.ts create mode 100644 src/serializer/cbor/transaction/MintTransactionCborSerializer.ts create mode 100644 src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts create mode 100644 src/serializer/cbor/transaction/TransactionCborSerializer.ts create mode 100644 src/serializer/cbor/transaction/TransactionDataCborSerializer.ts create mode 100644 src/serializer/json/token/TokenJsonSerializer.ts create mode 100644 src/serializer/json/token/TokenStateJsonSerializer.ts create mode 100644 src/serializer/json/transaction/CommitmentJsonSerializer.ts create mode 100644 src/serializer/json/transaction/ITransactionJson.ts create mode 100644 src/serializer/json/transaction/MintTransactionDataJsonSerializer.ts create mode 100644 src/serializer/json/transaction/MintTransactionJsonSerializer.ts create mode 100644 src/serializer/json/transaction/TransactionDataJsonSerializer.ts create mode 100644 src/serializer/json/transaction/TransactionJsonSerializer.ts rename src/serializer/token/{ITokenDeserializer.ts => ITokenSerializer.ts} (55%) delete mode 100644 src/serializer/token/TokenCborDeserializer.ts delete mode 100644 src/serializer/token/TokenJsonDeserializer.ts delete mode 100644 src/serializer/transaction/MintTransactionCborDeserializer.ts delete mode 100644 src/serializer/transaction/MintTransactionJsonDeserializer.ts delete mode 100644 src/serializer/transaction/TransactionCborDeserializer.ts delete mode 100644 src/serializer/transaction/TransactionJsonDeserializer.ts delete mode 100644 src/transaction/OfflineCommitment.ts delete mode 100644 src/transaction/OfflineTransaction.ts diff --git a/src/OfflineStateTransitionClient.ts b/src/OfflineStateTransitionClient.ts deleted file mode 100644 index 69d81f26..00000000 --- a/src/OfflineStateTransitionClient.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; -import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; -import { SubmitCommitmentStatus } from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js'; -import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js'; - -import { StateTransitionClient } from './StateTransitionClient.js'; -import { Commitment } from './transaction/Commitment.js'; -import { OfflineCommitment } from './transaction/OfflineCommitment.js'; -import { Transaction } from './transaction/Transaction.js'; -import { TransactionData } from './transaction/TransactionData.js'; -import { waitInclusionProof } from './utils/InclusionProofUtils.js'; - -/** - * High level client implementing the token state transition workflow. - */ -export class OfflineStateTransitionClient extends StateTransitionClient { - /** - * Create an offline commitment for a transaction (does not post it to the aggregator). - * - * @param transactionData - * @param signingService - */ - public async createOfflineCommitment( - transactionData: TransactionData, - signingService: SigningService, - ): Promise { - if (!(await transactionData.sourceState.unlockPredicate.isOwner(signingService.publicKey))) { - throw new Error('Failed to unlock token'); - } - - const requestId = await RequestId.create(signingService.publicKey, transactionData.sourceState.hash); - - const authenticator = await Authenticator.create( - signingService, - transactionData.hash, - transactionData.sourceState.hash, - ); - - return new OfflineCommitment(requestId, transactionData, authenticator); - } - - /** - * Submit an offline transaction commitment to the aggregator. - * - * @param requestId - * @param transactionData - * @param authenticator - */ - public async submitOfflineTransaction({ - requestId, - transactionData, - authenticator, - }: OfflineCommitment): Promise> { - const result = await this.client.submitTransaction(requestId, transactionData.hash, authenticator, false); - - if (result.status !== SubmitCommitmentStatus.SUCCESS) { - throw new Error(`Could not submit transaction: ${result.status}`); - } - - const commitment = new Commitment(requestId, transactionData, authenticator); - return await this.createTransaction(commitment, await waitInclusionProof(this, commitment)); - } -} diff --git a/src/StateTransitionClient.ts b/src/StateTransitionClient.ts index 1a2a68d1..ece7d126 100644 --- a/src/StateTransitionClient.ts +++ b/src/StateTransitionClient.ts @@ -1,7 +1,9 @@ -import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; import { InclusionProof, InclusionProofVerificationStatus } from '@unicitylabs/commons/lib/api/InclusionProof.js'; import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; -import { SubmitCommitmentStatus } from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js'; +import { + SubmitCommitmentResponse, + SubmitCommitmentStatus, +} from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js'; import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js'; import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js'; import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; @@ -57,17 +59,28 @@ export class StateTransitionClient { public async submitMintTransaction>( transactionData: T, ): Promise> { - return this.sendTransaction( + const commitment = await Commitment.create( transactionData, await SigningService.createFromSecret(MINTER_SECRET, transactionData.tokenId.bytes), ); + + const result = await this.client.submitTransaction( + commitment.requestId, + commitment.transactionData.hash, + commitment.authenticator, + ); + + if (result.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Could not submit transaction: ${result.status}`); + } + + return commitment; } /** * Submit a state transition for an existing token. * - * @param transactionData Data describing the transition - * @param signingService Signing service for the current owner + * @param {Commitment} commitment Commitment containing the request information * @returns Commitment ready for inclusion proof retrieval * @throws Error if ownership verification fails or aggregator rejects * @@ -76,15 +89,16 @@ export class StateTransitionClient { * const commitment = await client.submitTransaction(data, signingService); * ``` */ - public async submitTransaction( - transactionData: TransactionData, - signingService: SigningService, - ): Promise> { - if (!(await transactionData.sourceState.unlockPredicate.isOwner(signingService.publicKey))) { - throw new Error('Failed to unlock token'); + public submitCommitment(commitment: Commitment): Promise { + if (!commitment.transactionData.sourceState.unlockPredicate.isOwner(commitment.authenticator.publicKey)) { + throw new Error('Ownership verification failed: Authenticator does not match source state predicate.'); } - return this.sendTransaction(transactionData, signingService); + return this.client.submitTransaction( + commitment.requestId, + commitment.transactionData.hash, + commitment.authenticator, + ); } /** @@ -196,24 +210,4 @@ export class StateTransitionClient { ): Promise { return this.client.getInclusionProof(commitment.requestId); } - - private async sendTransaction>( - transactionData: TD, - signingService: SigningService, - ): Promise> { - const requestId = await RequestId.create(signingService.publicKey, transactionData.sourceState.hash); - - const authenticator = await Authenticator.create( - signingService, - transactionData.hash, - transactionData.sourceState.hash, - ); - const result = await this.client.submitTransaction(requestId, transactionData.hash, authenticator); - - if (result.status !== SubmitCommitmentStatus.SUCCESS) { - throw new Error(`Could not submit transaction: ${result.status}`); - } - - return new Commitment(requestId, transactionData, authenticator); - } } diff --git a/src/serializer/cbor/token/TokenCborSerializer.ts b/src/serializer/cbor/token/TokenCborSerializer.ts new file mode 100644 index 00000000..a7d5ed44 --- /dev/null +++ b/src/serializer/cbor/token/TokenCborSerializer.ts @@ -0,0 +1,89 @@ +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; + +import { TokenStateCborSerializer } from './TokenStateCborSerializer.js'; +import { ISerializable } from '../../../ISerializable.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { Token, TOKEN_VERSION } from '../../../token/Token.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; +import { ITokenSerializer } from '../../token/ITokenSerializer.js'; +import { MintTransactionCborSerializer } from '../transaction/MintTransactionCborSerializer.js'; +import { TransactionCborSerializer } from '../transaction/TransactionCborSerializer.js'; + +/** + * A serializer for {@link Token} objects using CBOR encoding. + * Handles serialization and deserialization of tokens, including their transactions and state. + */ +export class TokenCborSerializer implements ITokenSerializer { + private readonly mintTransactionSerializer: MintTransactionCborSerializer; + private readonly transactionSerializer: TransactionCborSerializer; + private stateSerializer: TokenStateCborSerializer; + + /** + * Constructs a new `TokenCborSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in token serialization. + */ + public constructor(private readonly predicateFactory: IPredicateFactory) { + this.mintTransactionSerializer = new MintTransactionCborSerializer(this); + this.transactionSerializer = new TransactionCborSerializer(predicateFactory); + this.stateSerializer = new TokenStateCborSerializer(predicateFactory); + } + + /** + * Serializes a `Token` object into a CBOR-encoded byte array. + * + * @param {Token>>} token - The token to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the token. + */ + public static serialize(token: Token>>): Uint8Array { + return CborEncoder.encodeArray([ + CborEncoder.encodeTextString(token.version), + MintTransactionCborSerializer.serialize(token.genesis), + CborEncoder.encodeArray( + token.transactions.map((transaction) => TransactionCborSerializer.serialize(transaction)), + ), + TokenStateCborSerializer.serialize(token.state), + CborEncoder.encodeArray(token.nametagTokens.map((token) => token.toCBOR())), + ]); + } + + /** + * Deserializes a CBOR-encoded `Uint8Array` into a `Token` object. + * + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise>>>} + * A promise that resolves to the deserialized `Token` object. + * @throws {Error} If the token version does not match the expected version. + */ + public async deserialize(bytes: Uint8Array): Promise>>> { + const data = CborDecoder.readArray(bytes); + const tokenVersion = CborDecoder.readTextString(data[0]); + if (tokenVersion !== TOKEN_VERSION) { + throw new Error(`Cannot parse token. Version mismatch: ${tokenVersion} !== ${TOKEN_VERSION}`); + } + + const mintTransaction = await this.mintTransactionSerializer.deserialize(data[1]); + const transactions: Transaction[] = []; + for (const transaction of CborDecoder.readArray(data[2])) { + transactions.push( + await this.transactionSerializer.deserialize( + mintTransaction.data.tokenId, + mintTransaction.data.tokenType, + transaction, + ), + ); + } + + // TODO: Add nametag tokens + return new Token( + await this.stateSerializer.deserialize(mintTransaction.data.tokenId, mintTransaction.data.tokenType, data[3]), + mintTransaction, + transactions, + [], + tokenVersion, + ); + } +} diff --git a/src/serializer/cbor/token/TokenStateCborSerializer.ts b/src/serializer/cbor/token/TokenStateCborSerializer.ts new file mode 100644 index 00000000..c59764b0 --- /dev/null +++ b/src/serializer/cbor/token/TokenStateCborSerializer.ts @@ -0,0 +1,49 @@ +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; + +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenState } from '../../../token/TokenState.js'; +import { TokenType } from '../../../token/TokenType.js'; + +/** + * A serializer for {@link TokenState} objects using CBOR encoding. + * Handles serialization and deserialization of token states. + */ +export class TokenStateCborSerializer { + /** + * Constructs a new `TokenStateCborSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in token state deserialization. + */ + public constructor(private readonly predicateFactory: IPredicateFactory) {} + + /** + * Serializes a `TokenState` object into a CBOR-encoded byte array. + * + * @param {TokenState} state - The token state to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the token state. + */ + public static serialize(state: TokenState): Uint8Array { + return CborEncoder.encodeArray([ + state.unlockPredicate.toCBOR(), + CborEncoder.encodeOptional(state.data, CborEncoder.encodeByteString), + ]); + } + + /** + * Deserializes a CBOR-encoded `Uint8Array` into a `TokenState` object. + * + * @param {TokenId} tokenId - The ID of the token associated with the state. + * @param {TokenType} tokenType - The type of the token associated with the state. + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise} A promise that resolves to the deserialized `TokenState` object. + */ + public async deserialize(tokenId: TokenId, tokenType: TokenType, bytes: Uint8Array): Promise { + const data = CborDecoder.readArray(bytes); + return TokenState.create( + await this.predicateFactory.create(tokenId, tokenType, data[0]), + CborDecoder.readOptional(data[1], CborDecoder.readByteString), + ); + } +} diff --git a/src/serializer/cbor/transaction/CommitmentCborSerializer.ts b/src/serializer/cbor/transaction/CommitmentCborSerializer.ts new file mode 100644 index 00000000..770401ef --- /dev/null +++ b/src/serializer/cbor/transaction/CommitmentCborSerializer.ts @@ -0,0 +1,63 @@ +import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; +import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; + +import { TransactionDataCborSerializer } from './TransactionDataCborSerializer.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { Commitment } from '../../../transaction/Commitment.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; + +/** + * A serializer for {@link Commitment} objects using CBOR encoding. + * Handles serialization and deserialization of commitments, including their associated transaction data. + */ +export class CommitmentCborSerializer { + private readonly transactionDataSerializer: TransactionDataCborSerializer; + + /** + * Constructs a new `CommitmentCborSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in transaction data deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.transactionDataSerializer = new TransactionDataCborSerializer(predicateFactory); + } + + /** + * Serializes a {@link Commitment} object into a CBOR-encoded byte array. + * + * @param {Commitment} commitment - The commitment to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the commitment. + */ + public static serialize(commitment: Commitment): Uint8Array { + return CborEncoder.encodeArray([ + commitment.requestId.toCBOR(), + TransactionDataCborSerializer.serialize(commitment.transactionData), + commitment.authenticator.toCBOR(), + ]); + } + + /** + * Deserializes a CBOR-encoded byte array into a {@link Commitment} object. + * + * @param {TokenId} tokenId - The ID of the token associated with the commitment. + * @param {TokenType} tokenType - The type of the token associated with the commitment. + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise>} A promise that resolves to the deserialized `Commitment` object. + */ + public async deserialize( + tokenId: TokenId, + tokenType: TokenType, + bytes: Uint8Array, + ): Promise> { + const data = CborDecoder.readArray(bytes); + return new Commitment( + RequestId.fromCBOR(data[0]), + await this.transactionDataSerializer.deserialize(tokenId, tokenType, data[1]), + Authenticator.fromCBOR(data[2]), + ); + } +} diff --git a/src/serializer/cbor/transaction/MintTransactionCborSerializer.ts b/src/serializer/cbor/transaction/MintTransactionCborSerializer.ts new file mode 100644 index 00000000..00d3379d --- /dev/null +++ b/src/serializer/cbor/transaction/MintTransactionCborSerializer.ts @@ -0,0 +1,54 @@ +import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; + +import { MintTransactionDataCborSerializer } from './MintTransactionDataCborSerializer.js'; +import { ISerializable } from '../../../ISerializable.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TokenCborSerializer } from '../token/TokenCborSerializer.js'; + +/** + * A serializer for {@link Transaction} containing {@link MintTransactionData} using CBOR encoding. + * Handles serialization and deserialization of mint transactions, including their data and inclusion proof. + */ +export class MintTransactionCborSerializer { + private readonly dataSerializer: MintTransactionDataCborSerializer; + + /** + * Constructs a new `MintTransactionCborSerializer` instance. + * + * @param {TokenCborSerializer} tokenSerializer - A serializer for tokens, used in mint transaction data serialization. + */ + public constructor(tokenSerializer: TokenCborSerializer) { + this.dataSerializer = new MintTransactionDataCborSerializer(tokenSerializer); + } + + /** + * Serializes a `Transaction` object containing `MintTransactionData` into a CBOR-encoded byte array. + * + * @param {Transaction>} transaction - The mint transaction to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the mint transaction. + */ + public static serialize(transaction: Transaction>): Uint8Array { + return CborEncoder.encodeArray([ + MintTransactionDataCborSerializer.serialize(transaction.data), + transaction.inclusionProof.toCBOR(), + ]); + } + + /** + * Deserializes a CBOR-encoded `Uint8Array` into a `Transaction` object containing `MintTransactionData`. + * + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise>>} + * A promise that resolves to the deserialized mint transaction. + */ + public async deserialize(bytes: Uint8Array): Promise>> { + const transaction = CborDecoder.readArray(bytes); + return new Transaction( + await this.dataSerializer.deserialize(transaction[0]), + InclusionProof.fromCBOR(transaction[1]), + ); + } +} diff --git a/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts b/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts new file mode 100644 index 00000000..14348f0d --- /dev/null +++ b/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts @@ -0,0 +1,94 @@ +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; +import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; +import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; + +import { ISerializable } from '../../../ISerializable.js'; +import { SplitMintReason } from '../../../token/fungible/SplitMintReason.js'; +import { SplitMintReasonProof } from '../../../token/fungible/SplitMintReasonProof.js'; +import { TokenCoinData } from '../../../token/fungible/TokenCoinData.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { MintReasonType } from '../../../transaction/MintReasonType.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { ITokenSerializer } from '../../token/ITokenSerializer.js'; + +/** + * A serializer for {@link MintTransactionData} objects using CBOR encoding. + * Handles serialization and deserialization of mint transaction data for tokens. + */ +export class MintTransactionDataCborSerializer { + /** + * Constructs a new MintTransactionDataCborSerializer. + * @param tokenSerializer Token serializer used for token-specific deserialization. + */ + public constructor(private readonly tokenSerializer: ITokenSerializer) {} + + /** + * Serializes MintTransactionData into a CBOR-encoded byte array. + * @param data The MintTransactionData to serialize. + * @returns CBOR-encoded byte array. + */ + public static serialize(data: MintTransactionData): Uint8Array { + return CborEncoder.encodeArray([ + data.tokenId.toCBOR(), + data.tokenType.toCBOR(), + CborEncoder.encodeByteString(data.tokenData), + data.coinData?.toCBOR() ?? CborEncoder.encodeNull(), + CborEncoder.encodeTextString(data.recipient), + CborEncoder.encodeByteString(data.salt), + data.dataHash?.toCBOR() ?? CborEncoder.encodeNull(), + data.reason?.toCBOR() ?? CborEncoder.encodeNull(), + ]); + } + + /** + * Deserializes a CBOR-encoded byte array into MintTransactionData. + * @param bytes The CBOR-encoded data. + * @returns A Promise resolving to the deserialized MintTransactionData. + */ + public async deserialize(bytes: Uint8Array): Promise> { + const data = CborDecoder.readArray(bytes); + return MintTransactionData.create( + TokenId.create(CborDecoder.readByteString(data[0])), + TokenType.create(CborDecoder.readByteString(data[1])), + CborDecoder.readByteString(data[2]), + CborDecoder.readOptional(data[3], TokenCoinData.fromCBOR), + CborDecoder.readTextString(data[4]), + CborDecoder.readByteString(data[5]), + CborDecoder.readOptional(data[6], DataHash.fromCBOR), + data[7] ? await this.createMintReason(data[7]) : null, + ); + } + + private createMintReason(bytes: Uint8Array): Promise { + const data = CborDecoder.readArray(bytes); + const type = CborDecoder.readTextString(data[0]); + switch (type) { + case MintReasonType.TOKEN_SPLIT: + return this.createSplitMintReason(bytes); + default: + throw new Error(`Unsupported mint reason type: ${type}`); + } + } + + private async createSplitMintReason(bytes: Uint8Array): Promise { + const data = CborDecoder.readArray(bytes); + const proofs = new Map(); + const token = await this.tokenSerializer.deserialize(data[0]); + const proofListBytes = CborDecoder.readArray(data[1]); + for (const proofBytes of proofListBytes) { + const proofWithCoin = CborDecoder.readArray(proofBytes); + const coinId = BigintConverter.decode(CborDecoder.readByteString(proofWithCoin[0])); + const proof = CborDecoder.readArray(proofWithCoin[1]); + proofs.set( + BigInt(coinId), + new SplitMintReasonProof(MerkleTreePath.fromCBOR(proof[0]), MerkleSumTreePath.fromCBOR(proof[1])), + ); + } + + return new SplitMintReason(token, proofs); + } +} diff --git a/src/serializer/cbor/transaction/TransactionCborSerializer.ts b/src/serializer/cbor/transaction/TransactionCborSerializer.ts new file mode 100644 index 00000000..b02dcb5b --- /dev/null +++ b/src/serializer/cbor/transaction/TransactionCborSerializer.ts @@ -0,0 +1,62 @@ +import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; + +import { TransactionDataCborSerializer } from './TransactionDataCborSerializer.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; + +/** + * A serializer for {@link Transaction} containing {@link TransactionData} using CBOR encoding. + * Handles serialization and deserialization of transactions. + */ +export class TransactionCborSerializer { + private readonly dataSerializer: TransactionDataCborSerializer; + + /** + * Constructs a new `TransactionCborSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in transaction data deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.dataSerializer = new TransactionDataCborSerializer(predicateFactory); + } + + /** + * Serializes a `Transaction` object containing `TransactionData` into a CBOR-encoded byte array. + * + * @param {Transaction} transaction - The transaction to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the transaction. + */ + public static serialize(transaction: Transaction): Uint8Array { + return CborEncoder.encodeArray([ + TransactionDataCborSerializer.serialize(transaction.data), + transaction.inclusionProof.toCBOR(), + ]); + } + + /** + * Deserializes a CBOR-encoded `Uint8Array` into a `Transaction` object containing `TransactionData`. + * + * @param {TokenId} tokenId - The ID of the token associated with the transaction. + * @param {TokenType} tokenType - The type of the token associated with the transaction. + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise>} + * A promise that resolves to the deserialized transaction. + */ + public async deserialize( + tokenId: TokenId, + tokenType: TokenType, + bytes: Uint8Array, + ): Promise> { + const transaction = CborDecoder.readArray(bytes); + + return new Transaction( + await this.dataSerializer.deserialize(tokenId, tokenType, transaction[0]), + InclusionProof.fromCBOR(transaction[1]), + ); + } +} diff --git a/src/serializer/cbor/transaction/TransactionDataCborSerializer.ts b/src/serializer/cbor/transaction/TransactionDataCborSerializer.ts new file mode 100644 index 00000000..b1a7960f --- /dev/null +++ b/src/serializer/cbor/transaction/TransactionDataCborSerializer.ts @@ -0,0 +1,66 @@ +import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; +import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; + +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; +import { TokenStateCborSerializer } from '../token/TokenStateCborSerializer.js'; + +/** + * A serializer for {@link TransactionData} objects using CBOR encoding. + * Handles serialization and deserialization of transaction data. + */ +export class TransactionDataCborSerializer { + private readonly tokenStateSerializer: TokenStateCborSerializer; + + /** + * Constructs a new `TransactionDataCborSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in transaction data deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.tokenStateSerializer = new TokenStateCborSerializer(predicateFactory); + } + + /** + * Serializes a `TransactionData` object into a CBOR-encoded byte array. + * + * @param {TransactionData} data - The transaction data to serialize. + * @returns {Uint8Array} The CBOR-encoded representation of the transaction data. + */ + public static serialize(data: TransactionData): Uint8Array { + const message = data.message; + + return CborEncoder.encodeArray([ + TokenStateCborSerializer.serialize(data.sourceState), + CborEncoder.encodeTextString(data.recipient), + CborEncoder.encodeByteString(data.salt), + data.dataHash?.toCBOR() ?? CborEncoder.encodeNull(), + CborEncoder.encodeOptional(message, CborEncoder.encodeByteString), + CborEncoder.encodeArray(data.nametagTokens.map((token) => token.toCBOR())), + ]); + } + + /** + * Deserializes a CBOR-encoded `Uint8Array` into a `TransactionData` object. + * + * @param {TokenId} tokenId - The ID of the token associated with the transaction data. + * @param {TokenType} tokenType - The type of the token associated with the transaction data. + * @param {Uint8Array} bytes - The CBOR-encoded data to deserialize. + * @returns {Promise} A promise that resolves to the deserialized `TransactionData` object. + */ + public async deserialize(tokenId: TokenId, tokenType: TokenType, bytes: Uint8Array): Promise { + const data = CborDecoder.readArray(bytes); + + return TransactionData.create( + await this.tokenStateSerializer.deserialize(tokenId, tokenType, data[0]), + CborDecoder.readTextString(data[1]), + CborDecoder.readByteString(data[2]), + CborDecoder.readOptional(data[3], DataHash.fromCBOR), + CborDecoder.readOptional(data[4], CborDecoder.readByteString), + [], + ); + } +} diff --git a/src/serializer/json/token/TokenJsonSerializer.ts b/src/serializer/json/token/TokenJsonSerializer.ts new file mode 100644 index 00000000..2dc25e35 --- /dev/null +++ b/src/serializer/json/token/TokenJsonSerializer.ts @@ -0,0 +1,98 @@ +import { ITokenStateJson, TokenStateJsonSerializer } from './TokenStateJsonSerializer.js'; +import { ISerializable } from '../../../ISerializable.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { Token, TOKEN_VERSION } from '../../../token/Token.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; +import { ITokenSerializer } from '../../token/ITokenSerializer.js'; +import { ITransactionJson } from '../transaction/ITransactionJson.js'; +import { IMintTransactionDataJson } from '../transaction/MintTransactionDataJsonSerializer.js'; +import { MintTransactionJsonSerializer } from '../transaction/MintTransactionJsonSerializer.js'; +import { ITransactionDataJson } from '../transaction/TransactionDataJsonSerializer.js'; +import { TransactionJsonSerializer } from '../transaction/TransactionJsonSerializer.js'; + +/** + * JSON representation of a {@link Token}. + */ +export interface ITokenJson { + readonly version: string; + readonly state: ITokenStateJson; + readonly genesis: ITransactionJson; + readonly transactions: ITransactionJson[]; + readonly nametagTokens: ITokenJson[]; +} + +/** + * A serializer for {@link Token} objects using JSON encoding. + * Handles serialization and deserialization of tokens, including their transactions and state. + */ +export class TokenJsonSerializer implements ITokenSerializer { + private readonly mintTransactionDeserializer: MintTransactionJsonSerializer; + private readonly transactionSerializer: TransactionJsonSerializer; + private readonly stateSerializer: TokenStateJsonSerializer; + + /** + * Constructs a new `TokenJsonSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in token serialization. + */ + public constructor(private readonly predicateFactory: IPredicateFactory) { + this.mintTransactionDeserializer = new MintTransactionJsonSerializer(this); + this.transactionSerializer = new TransactionJsonSerializer(predicateFactory); + this.stateSerializer = new TokenStateJsonSerializer(predicateFactory); + } + + /** + * Serializes a `Token` object into a JSON representation. + * + * @param {Token>>} token - The token to serialize. + * @returns {ITokenJson} The JSON representation of the token. + */ + public static serialize(token: Token>>): ITokenJson { + return { + genesis: MintTransactionJsonSerializer.serialize(token.genesis), + nametagTokens: [], + state: TokenStateJsonSerializer.serialize(token.state), + transactions: token.transactions.map((transaction) => TransactionJsonSerializer.serialize(transaction)), + version: token.version, + }; + } + + /** + * Deserializes a JSON representation of a token into a `Token` object. + * + * @param {ITokenJson} data - The JSON data to deserialize. + * @returns {Promise>>>} + * A promise that resolves to the deserialized `Token` object. + * @throws {Error} If the token version does not match the expected version. + */ + public async deserialize(data: ITokenJson): Promise>>> { + const tokenVersion = data.version; + if (tokenVersion !== TOKEN_VERSION) { + throw new Error(`Cannot parse token. Version mismatch: ${tokenVersion} !== ${TOKEN_VERSION}`); + } + + const mintTransaction = await this.mintTransactionDeserializer.deserialize(data.genesis); + + const transactions: Transaction[] = []; + for (const transaction of data.transactions) { + transactions.push( + await this.transactionSerializer.deserialize( + mintTransaction.data.tokenId, + mintTransaction.data.tokenType, + transaction, + ), + ); + } + + // TODO: Add nametag tokens + return new Token( + await this.stateSerializer.deserialize(mintTransaction.data.tokenId, mintTransaction.data.tokenType, data.state), + mintTransaction, + transactions, + [], + tokenVersion, + ); + } +} diff --git a/src/serializer/json/token/TokenStateJsonSerializer.ts b/src/serializer/json/token/TokenStateJsonSerializer.ts new file mode 100644 index 00000000..1e8df1cb --- /dev/null +++ b/src/serializer/json/token/TokenStateJsonSerializer.ts @@ -0,0 +1,55 @@ +import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; + +import { IPredicateJson } from '../../../predicate/IPredicate.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenState } from '../../../token/TokenState.js'; +import { TokenType } from '../../../token/TokenType.js'; + +/** JSON representation of {@link TokenState}. */ +export interface ITokenStateJson { + readonly unlockPredicate: IPredicateJson; + readonly data: string | null; +} + +/** + * A serializer for {@link TokenState} objects using JSON encoding. + * Handles serialization and deserialization of token states. + */ +export class TokenStateJsonSerializer { + /** + * Constructs a new `TokenStateJsonSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in token state deserialization. + */ + public constructor(private readonly predicateFactory: IPredicateFactory) {} + + /** + * Serializes a `TokenState` object into a JSON representation. + * + * @param {TokenState} state - The token state to serialize. + * @returns {ITokenStateJson} The JSON representation of the token state. + */ + public static serialize(state: TokenState): ITokenStateJson { + const data = state.data; + return { + data: data ? HexConverter.encode(data) : null, + unlockPredicate: state.unlockPredicate.toJSON(), + }; + } + + /** + * Deserializes a JSON representation into a `TokenState` object. + * + * @param {TokenId} tokenId - The ID of the token associated with the state. + * @param {TokenType} tokenType - The type of the token associated with the state. + * @param {ITokenStateJson} state - The JSON data to deserialize. + * @returns {Promise} A promise that resolves to the deserialized `TokenState` object. + */ + public async deserialize(tokenId: TokenId, tokenType: TokenType, state: ITokenStateJson): Promise { + return TokenState.create( + await this.predicateFactory.create(tokenId, tokenType, state.unlockPredicate), + state.data ? HexConverter.decode(state.data) : null, + ); + } +} diff --git a/src/serializer/json/transaction/CommitmentJsonSerializer.ts b/src/serializer/json/transaction/CommitmentJsonSerializer.ts new file mode 100644 index 00000000..21c6d054 --- /dev/null +++ b/src/serializer/json/transaction/CommitmentJsonSerializer.ts @@ -0,0 +1,68 @@ +import type { IAuthenticatorJson } from '@unicitylabs/commons/lib/api/Authenticator.js'; +import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; +import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; + +import { ITransactionDataJson, TransactionDataJsonSerializer } from './TransactionDataJsonSerializer.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { Commitment } from '../../../transaction/Commitment.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; + +/** JSON representation of an {@link Commitment}. */ +export interface ICommitmentJson { + readonly requestId: string; + readonly transactionData: ITransactionDataJson; + readonly authenticator: IAuthenticatorJson; +} + +/** + * A serializer for {@link Commitment} objects using JSON encoding. + * Handles serialization and deserialization of commitments, including their associated transaction data. + */ +export class CommitmentJsonSerializer { + private readonly transactionDataSerializer: TransactionDataJsonSerializer; + + /** + * Constructs a new `CommitmentJsonSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in transaction data deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.transactionDataSerializer = new TransactionDataJsonSerializer(predicateFactory); + } + + /** + * Serializes a {@link Commitment} object into a JSON representation. + * + * @param {Commitment} commitment - The commitment to serialize. + * @returns {ICommitmentJson} The JSON representation of the commitment. + */ + public static serialize(commitment: Commitment): ICommitmentJson { + return { + authenticator: commitment.authenticator.toJSON(), + requestId: commitment.requestId.toJSON(), + transactionData: TransactionDataJsonSerializer.serialize(commitment.transactionData), + }; + } + + /** + * Deserializes a JSON representation into a {@link Commitment} object. + * + * @param {TokenId} tokenId - The ID of the token associated with the commitment. + * @param {TokenType} tokenType - The type of the token associated with the commitment. + * @param {ICommitmentJson} data - The JSON data to deserialize. + * @returns {Promise>} A promise that resolves to the deserialized `Commitment` object. + */ + public async deserialize( + tokenId: TokenId, + tokenType: TokenType, + data: ICommitmentJson, + ): Promise> { + return new Commitment( + RequestId.fromJSON(data.requestId), + await this.transactionDataSerializer.deserialize(tokenId, tokenType, data.transactionData), + Authenticator.fromJSON(data.authenticator), + ); + } +} diff --git a/src/serializer/json/transaction/ITransactionJson.ts b/src/serializer/json/transaction/ITransactionJson.ts new file mode 100644 index 00000000..6a3c197a --- /dev/null +++ b/src/serializer/json/transaction/ITransactionJson.ts @@ -0,0 +1,13 @@ +import type { IInclusionProofJson } from '@unicitylabs/commons/lib/api/InclusionProof.js'; + +import { IMintTransactionDataJson } from './MintTransactionDataJsonSerializer.js'; +import { ITransactionDataJson } from './TransactionDataJsonSerializer.js'; + +/** + * JSON representation of a transaction, which can either be a standard transaction or a mint transaction. + * Contains the transaction data and an inclusion proof. + */ +export interface ITransactionJson { + readonly data: T; + readonly inclusionProof: IInclusionProofJson; +} diff --git a/src/serializer/json/transaction/MintTransactionDataJsonSerializer.ts b/src/serializer/json/transaction/MintTransactionDataJsonSerializer.ts new file mode 100644 index 00000000..14dc6cbb --- /dev/null +++ b/src/serializer/json/transaction/MintTransactionDataJsonSerializer.ts @@ -0,0 +1,108 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; +import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; +import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; + +import { ISerializable } from '../../../ISerializable.js'; +import { ISplitMintReasonJson, SplitMintReason } from '../../../token/fungible/SplitMintReason.js'; +import { SplitMintReasonProof } from '../../../token/fungible/SplitMintReasonProof.js'; +import { TokenCoinData, TokenCoinDataJson } from '../../../token/fungible/TokenCoinData.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { MintReasonType } from '../../../transaction/MintReasonType.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { TokenJsonSerializer } from '../token/TokenJsonSerializer.js'; + +/** + * JSON representation of a mint reason. + */ +export interface IMintReasonJson { + readonly type: string; +} + +/** JSON representation of {@link MintTransactionData}. */ +export interface IMintTransactionDataJson { + readonly tokenId: string; + readonly tokenType: string; + readonly tokenData: string; + readonly coins: TokenCoinDataJson | null; + readonly recipient: string; + readonly salt: string; + readonly dataHash: string | null; + readonly reason: unknown | null; +} + +/** + * A serializer for {@link MintTransactionData} objects using JSON encoding. + * Handles serialization and deserialization of mint transaction data for tokens. + */ +export class MintTransactionDataJsonSerializer { + /** + * Constructs a new `MintTransactionDataJsonSerializer` instance. + * + * @param tokenSerializer Token serializer used for token-specific deserialization. + */ + public constructor(private readonly tokenSerializer: TokenJsonSerializer) {} + + /** + * Serializes `MintTransactionData` into a JSON representation. + * + * @param data The `MintTransactionData` to serialize. + * @returns JSON representation of the mint transaction data. + */ + public static serialize(data: MintTransactionData): IMintTransactionDataJson { + return { + coins: data.coinData?.toJSON() ?? null, + dataHash: data.dataHash?.toJSON() ?? null, + reason: data.reason?.toJSON() ?? null, + recipient: data.recipient, + salt: HexConverter.encode(data.salt), + tokenData: HexConverter.encode(data.tokenData), + tokenId: data.tokenId.toJSON(), + tokenType: data.tokenType.toJSON(), + }; + } + + /** + * Deserializes a JSON representation into `MintTransactionData`. + * + * @param data The JSON data to deserialize. + * @returns A promise that resolves to the deserialized `MintTransactionData`. + */ + public async deserialize(data: IMintTransactionDataJson): Promise> { + return MintTransactionData.create( + TokenId.create(HexConverter.decode(data.tokenId)), + TokenType.create(HexConverter.decode(data.tokenType)), + HexConverter.decode(data.tokenData), + data.coins ? TokenCoinData.fromJSON(data.coins) : null, + data.recipient, + HexConverter.decode(data.salt), + data.dataHash ? DataHash.fromJSON(data.dataHash) : null, + data.reason ? await this.createMintReason(data.reason as IMintReasonJson) : null, + ); + } + + private createMintReason(data: IMintReasonJson): Promise { + switch (data.type) { + case MintReasonType.TOKEN_SPLIT: + return this.createSplitMintReason(data as ISplitMintReasonJson); + default: + throw new Error(`Unsupported mint reason type: ${data.type}`); + } + } + + private async createSplitMintReason(data: ISplitMintReasonJson): Promise { + const proofs = new Map(); + for (const [coinId, proof] of data.proofs) { + proofs.set( + BigInt(coinId), + new SplitMintReasonProof( + MerkleTreePath.fromJSON(proof.aggregationPath), + MerkleSumTreePath.fromJSON(proof.coinTreePath), + ), + ); + } + + return new SplitMintReason(await this.tokenSerializer.deserialize(data.token), proofs); + } +} diff --git a/src/serializer/json/transaction/MintTransactionJsonSerializer.ts b/src/serializer/json/transaction/MintTransactionJsonSerializer.ts new file mode 100644 index 00000000..bdc9643b --- /dev/null +++ b/src/serializer/json/transaction/MintTransactionJsonSerializer.ts @@ -0,0 +1,54 @@ +import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; + +import { ITransactionJson } from './ITransactionJson.js'; +import { IMintTransactionDataJson, MintTransactionDataJsonSerializer } from './MintTransactionDataJsonSerializer.js'; +import { ISerializable } from '../../../ISerializable.js'; +import { MintTransactionData } from '../../../transaction/MintTransactionData.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TokenJsonSerializer } from '../token/TokenJsonSerializer.js'; + +/** + * A serializer for {@link Transaction} containing {@link MintTransactionData} objects using JSON encoding. + * Handles serialization and deserialization of mint transactions, including their data and inclusion proof. + */ +export class MintTransactionJsonSerializer { + private readonly dataSerializer: MintTransactionDataJsonSerializer; + + /** + * Constructs a new `MintTransactionJsonSerializer` instance. + * + * @param tokenSerializer A serializer for tokens, used in mint transaction data serialization. + */ + public constructor(tokenSerializer: TokenJsonSerializer) { + this.dataSerializer = new MintTransactionDataJsonSerializer(tokenSerializer); + } + + /** + * Serializes a `Transaction` object containing `MintTransactionData` into a JSON representation. + * + * @param transaction The mint transaction to serialize. + * @returns JSON representation of the mint transaction. + */ + public static serialize( + transaction: Transaction>, + ): ITransactionJson { + return { + data: MintTransactionDataJsonSerializer.serialize(transaction.data), + inclusionProof: transaction.inclusionProof.toJSON(), + }; + } + + /** + * Deserializes a JSON representation of a mint transaction into a `Transaction` object containing `MintTransactionData`. + * + * @param data The JSON data to deserialize. + * @param inclusionProof The inclusion proof associated with the transaction. + * @returns A promise that resolves to the deserialized mint transaction. + */ + public async deserialize({ + data, + inclusionProof, + }: ITransactionJson): Promise>> { + return new Transaction(await this.dataSerializer.deserialize(data), InclusionProof.fromJSON(inclusionProof)); + } +} diff --git a/src/serializer/json/transaction/TransactionDataJsonSerializer.ts b/src/serializer/json/transaction/TransactionDataJsonSerializer.ts new file mode 100644 index 00000000..4ee4d73b --- /dev/null +++ b/src/serializer/json/transaction/TransactionDataJsonSerializer.ts @@ -0,0 +1,78 @@ +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; + +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; +import { ITokenJson } from '../token/TokenJsonSerializer.js'; +import { ITokenStateJson, TokenStateJsonSerializer } from '../token/TokenStateJsonSerializer.js'; + +/** JSON representation of a {@link TransactionData}. */ +export interface ITransactionDataJson { + readonly sourceState: ITokenStateJson; + readonly recipient: string; + readonly salt: string; + readonly dataHash: string | null; + readonly message: string | null; + readonly nameTags: ITokenJson[]; +} + +/** + * A serializer for {@link TransactionData} objects using JSON encoding. + * Handles serialization and deserialization of transaction data, including token states and metadata. + */ +export class TransactionDataJsonSerializer { + private readonly tokenStateSerializer: TokenStateJsonSerializer; + + /** + * Constructs a new `TransactionDataJsonSerializer` instance. + * + * @param {IPredicateFactory} predicateFactory - A factory for creating predicates used in token state deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.tokenStateSerializer = new TokenStateJsonSerializer(predicateFactory); + } + + /** + * Serializes `TransactionData` into a JSON representation. + * + * @param data The `TransactionData` to serialize. + * @returns JSON representation of the transaction data. + */ + public static serialize(data: TransactionData): ITransactionDataJson { + const message = data.message; + + return { + dataHash: data.dataHash?.toJSON() ?? null, + message: message ? HexConverter.encode(message) : null, + nameTags: [], + recipient: data.recipient, + salt: HexConverter.encode(data.salt), + sourceState: TokenStateJsonSerializer.serialize(data.sourceState), + }; + } + + /** + * Deserializes a JSON representation into a `TransactionData` object. + * + * @param tokenId The ID of the token associated with the transaction data. + * @param tokenType The type of the token associated with the transaction data. + * @param data The JSON data to deserialize. + * @returns A promise that resolves to the deserialized `TransactionData` object. + */ + public async deserialize( + tokenId: TokenId, + tokenType: TokenType, + data: ITransactionDataJson, + ): Promise { + return TransactionData.create( + await this.tokenStateSerializer.deserialize(tokenId, tokenType, data.sourceState), + data.recipient, + HexConverter.decode(data.salt), + data.dataHash ? DataHash.fromJSON(data.dataHash) : null, + data.message ? HexConverter.decode(data.message) : null, + [], + ); + } +} diff --git a/src/serializer/json/transaction/TransactionJsonSerializer.ts b/src/serializer/json/transaction/TransactionJsonSerializer.ts new file mode 100644 index 00000000..a4a841d2 --- /dev/null +++ b/src/serializer/json/transaction/TransactionJsonSerializer.ts @@ -0,0 +1,58 @@ +import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; + +import { ITransactionJson } from './ITransactionJson.js'; +import { ITransactionDataJson, TransactionDataJsonSerializer } from './TransactionDataJsonSerializer.js'; +import { IPredicateFactory } from '../../../predicate/IPredicateFactory.js'; +import { TokenId } from '../../../token/TokenId.js'; +import { TokenType } from '../../../token/TokenType.js'; +import { Transaction } from '../../../transaction/Transaction.js'; +import { TransactionData } from '../../../transaction/TransactionData.js'; + +/** + * A serializer for {@link Transaction} containing {@link TransactionData} objects using JSON encoding. + * Handles serialization and deserialization of transactions, including their data and inclusion proofs. + */ +export class TransactionJsonSerializer { + private readonly dataSerializer: TransactionDataJsonSerializer; + + /** + * Constructs a new `TransactionJsonSerializer` instance. + * + * @param predicateFactory A factory for creating predicates used in transaction data deserialization. + */ + public constructor(predicateFactory: IPredicateFactory) { + this.dataSerializer = new TransactionDataJsonSerializer(predicateFactory); + } + + /** + * Serializes a `Transaction` object containing `TransactionData` into a JSON representation. + * + * @param transaction The transaction to serialize. + * @returns JSON representation of the transaction. + */ + public static serialize(transaction: Transaction): ITransactionJson { + return { + data: TransactionDataJsonSerializer.serialize(transaction.data), + inclusionProof: transaction.inclusionProof.toJSON(), + }; + } + + /** + * Deserializes a JSON representation of a transaction into a `Transaction` object containing `TransactionData`. + * + * @param tokenId The ID of the token associated with the transaction. + * @param tokenType The type of the token associated with the transaction. + * @param data The JSON data to deserialize. + * @returns A promise that resolves to the deserialized transaction. + */ + public async deserialize( + tokenId: TokenId, + tokenType: TokenType, + { data, inclusionProof }: ITransactionJson, + ): Promise> { + return new Transaction( + await this.dataSerializer.deserialize(tokenId, tokenType, data), + InclusionProof.fromJSON(inclusionProof), + ); + } +} diff --git a/src/serializer/token/ITokenDeserializer.ts b/src/serializer/token/ITokenSerializer.ts similarity index 55% rename from src/serializer/token/ITokenDeserializer.ts rename to src/serializer/token/ITokenSerializer.ts index b5887388..c77f58ce 100644 --- a/src/serializer/token/ITokenDeserializer.ts +++ b/src/serializer/token/ITokenSerializer.ts @@ -3,6 +3,14 @@ import { Token } from '../../token/Token.js'; import { MintTransactionData } from '../../transaction/MintTransactionData.js'; import { Transaction } from '../../transaction/Transaction.js'; -export interface ITokenDeserializer { +/** + * Interface for token serializers capable of deserializing token transaction data. + */ +export interface ITokenSerializer { + /** + * Deserializes data into a Token. + * @param data The data to deserialize. + * @returns A Promise resolving to the deserialized Token. + */ deserialize(data: unknown): Promise>>>; } diff --git a/src/serializer/token/TokenCborDeserializer.ts b/src/serializer/token/TokenCborDeserializer.ts deleted file mode 100644 index 2bc16b66..00000000 --- a/src/serializer/token/TokenCborDeserializer.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; - -import { ITokenDeserializer } from './ITokenDeserializer.js'; -import { ISerializable } from '../../ISerializable.js'; -import { IPredicateFactory } from '../../predicate/IPredicateFactory.js'; -import { Token, TOKEN_VERSION } from '../../token/Token.js'; -import { TokenState } from '../../token/TokenState.js'; -import { MintTransactionData } from '../../transaction/MintTransactionData.js'; -import { Transaction } from '../../transaction/Transaction.js'; -import { TransactionData } from '../../transaction/TransactionData.js'; -import { MintTransactionCborDeserializer } from '../transaction/MintTransactionCborDeserializer.js'; -import { TransactionCborDeserializer } from '../transaction/TransactionCborDeserializer.js'; - -export class TokenCborDeserializer implements ITokenDeserializer { - private readonly mintTransactionDeserializer: MintTransactionCborDeserializer; - private readonly transactionDeserializer: TransactionCborDeserializer; - - public constructor(private readonly predicateFactory: IPredicateFactory) { - this.mintTransactionDeserializer = new MintTransactionCborDeserializer(this); - this.transactionDeserializer = new TransactionCborDeserializer(predicateFactory); - } - - public async deserialize(bytes: Uint8Array): Promise>>> { - const data = CborDecoder.readArray(bytes); - const tokenVersion = CborDecoder.readTextString(data[0]); - if (tokenVersion !== TOKEN_VERSION) { - throw new Error(`Cannot parse token. Version mismatch: ${tokenVersion} !== ${TOKEN_VERSION}`); - } - - const mintTransaction = await this.mintTransactionDeserializer.deserialize(data[1]); - const transactions: Transaction[] = []; - for (const transaction of CborDecoder.readArray(data[2])) { - transactions.push( - await this.transactionDeserializer.deserialize( - mintTransaction.data.tokenId, - mintTransaction.data.tokenType, - transaction, - ), - ); - } - - const state = CborDecoder.readArray(data[3]); - - // TODO: Add nametag tokens - return new Token( - await TokenState.create( - await this.predicateFactory.create(mintTransaction.data.tokenId, mintTransaction.data.tokenType, state[0]), - CborDecoder.readByteString(state[1]), - ), - mintTransaction, - transactions, - [], - tokenVersion, - ); - } -} diff --git a/src/serializer/token/TokenJsonDeserializer.ts b/src/serializer/token/TokenJsonDeserializer.ts deleted file mode 100644 index 5cf1fc0a..00000000 --- a/src/serializer/token/TokenJsonDeserializer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; - -import { ITokenDeserializer } from './ITokenDeserializer.js'; -import { ISerializable } from '../../ISerializable.js'; -import { IPredicateFactory } from '../../predicate/IPredicateFactory.js'; -import { ITokenJson, Token, TOKEN_VERSION } from '../../token/Token.js'; -import { TokenState } from '../../token/TokenState.js'; -import { MintTransactionData } from '../../transaction/MintTransactionData.js'; -import { Transaction } from '../../transaction/Transaction.js'; -import { TransactionData } from '../../transaction/TransactionData.js'; -import { MintTransactionJsonDeserializer } from '../transaction/MintTransactionJsonDeserializer.js'; -import { TransactionJsonDeserializer } from '../transaction/TransactionJsonDeserializer.js'; - -export class TokenJsonDeserializer implements ITokenDeserializer { - private readonly mintTransactionDeserializer: MintTransactionJsonDeserializer; - private readonly transactionDeserializer: TransactionJsonDeserializer; - - public constructor(private readonly predicateFactory: IPredicateFactory) { - this.mintTransactionDeserializer = new MintTransactionJsonDeserializer(this); - this.transactionDeserializer = new TransactionJsonDeserializer(predicateFactory); - } - - public async deserialize(data: ITokenJson): Promise>>> { - const tokenVersion = data.version; - if (tokenVersion !== TOKEN_VERSION) { - throw new Error(`Cannot parse token. Version mismatch: ${tokenVersion} !== ${TOKEN_VERSION}`); - } - - const mintTransaction = await this.mintTransactionDeserializer.deserialize(data.genesis); - - const transactions: Transaction[] = []; - for (const transaction of data.transactions) { - transactions.push( - await this.transactionDeserializer.deserialize( - mintTransaction.data.tokenId, - mintTransaction.data.tokenType, - transaction, - ), - ); - } - - // TODO: Add nametag tokens - return new Token( - await TokenState.create( - await this.predicateFactory.create( - mintTransaction.data.tokenId, - mintTransaction.data.tokenType, - data.state.unlockPredicate, - ), - data.state.data ? HexConverter.decode(data.state.data) : null, - ), - mintTransaction, - transactions, - [], - tokenVersion, - ); - } -} diff --git a/src/serializer/transaction/MintTransactionCborDeserializer.ts b/src/serializer/transaction/MintTransactionCborDeserializer.ts deleted file mode 100644 index eabb4400..00000000 --- a/src/serializer/transaction/MintTransactionCborDeserializer.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; -import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; -import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; -import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; -import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; - -import { ISerializable } from '../../ISerializable.js'; -import { SplitMintReason } from '../../token/fungible/SplitMintReason.js'; -import { SplitMintReasonProof } from '../../token/fungible/SplitMintReasonProof.js'; -import { TokenCoinData } from '../../token/fungible/TokenCoinData.js'; -import { TokenId } from '../../token/TokenId.js'; -import { TokenType } from '../../token/TokenType.js'; -import { MintReasonType } from '../../transaction/MintReasonType.js'; -import { MintTransactionData } from '../../transaction/MintTransactionData.js'; -import { Transaction } from '../../transaction/Transaction.js'; -import { ITokenDeserializer } from '../token/ITokenDeserializer.js'; - -export class MintTransactionCborDeserializer { - public constructor(private readonly tokenDeserializer: ITokenDeserializer) {} - - public async deserialize(bytes: Uint8Array): Promise>> { - const transaction = CborDecoder.readArray(bytes); - const data = CborDecoder.readArray(transaction[0]); - return new Transaction( - await MintTransactionData.create( - TokenId.create(CborDecoder.readByteString(data[0])), - TokenType.create(CborDecoder.readByteString(data[1])), - CborDecoder.readByteString(data[2]), - CborDecoder.readOptional(data[3], TokenCoinData.fromCBOR), - CborDecoder.readTextString(data[4]), - CborDecoder.readByteString(data[5]), - CborDecoder.readOptional(data[6], DataHash.fromCBOR), - data[7] ? await this.createMintReason(data[7]) : null, - ), - InclusionProof.fromCBOR(transaction[1]), - ); - } - - private createMintReason(bytes: Uint8Array): Promise { - const data = CborDecoder.readArray(bytes); - const type = CborDecoder.readTextString(data[0]); - switch (type) { - case MintReasonType.TOKEN_SPLIT: - return this.createSplitMintReason(bytes); - default: - throw new Error(`Unsupported mint reason type: ${type}`); - } - } - - private async createSplitMintReason(bytes: Uint8Array): Promise { - const data = CborDecoder.readArray(bytes); - const proofs = new Map(); - const token = await this.tokenDeserializer.deserialize(data[0]); - const proofListBytes = CborDecoder.readArray(data[1]); - for (const proofBytes of proofListBytes) { - const proofWithCoin = CborDecoder.readArray(proofBytes); - const coinId = BigintConverter.decode(CborDecoder.readByteString(proofWithCoin[0])); - const proof = CborDecoder.readArray(proofWithCoin[1]); - proofs.set( - BigInt(coinId), - new SplitMintReasonProof(MerkleTreePath.fromCBOR(proof[0]), MerkleSumTreePath.fromCBOR(proof[1])), - ); - } - - return new SplitMintReason(token, proofs); - } -} diff --git a/src/serializer/transaction/MintTransactionJsonDeserializer.ts b/src/serializer/transaction/MintTransactionJsonDeserializer.ts deleted file mode 100644 index 81495e63..00000000 --- a/src/serializer/transaction/MintTransactionJsonDeserializer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; -import { MerkleSumTreePath } from '@unicitylabs/commons/lib/smst/MerkleSumTreePath.js'; -import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; -import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; - -import { ISerializable } from '../../ISerializable.js'; -import { ISplitMintReasonJson, SplitMintReason } from '../../token/fungible/SplitMintReason.js'; -import { SplitMintReasonProof } from '../../token/fungible/SplitMintReasonProof.js'; -import { TokenCoinData } from '../../token/fungible/TokenCoinData.js'; -import { TokenId } from '../../token/TokenId.js'; -import { TokenType } from '../../token/TokenType.js'; -import { MintReasonType } from '../../transaction/MintReasonType.js'; -import { - IMintReasonJson, - IMintTransactionDataJson, - MintTransactionData, -} from '../../transaction/MintTransactionData.js'; -import { ITransactionJson, Transaction } from '../../transaction/Transaction.js'; -import { ITokenDeserializer } from '../token/ITokenDeserializer.js'; - -export class MintTransactionJsonDeserializer { - public constructor(private readonly tokenDeserializer: ITokenDeserializer) {} - - public async deserialize({ - data, - inclusionProof, - }: ITransactionJson): Promise>> { - return new Transaction( - await MintTransactionData.create( - TokenId.create(HexConverter.decode(data.tokenId)), - TokenType.create(HexConverter.decode(data.tokenType)), - HexConverter.decode(data.tokenData), - data.coins ? TokenCoinData.fromJSON(data.coins) : null, - data.recipient, - HexConverter.decode(data.salt), - data.dataHash ? DataHash.fromJSON(data.dataHash) : null, - data.reason ? await this.createMintReason(data.reason as IMintReasonJson) : null, - ), - InclusionProof.fromJSON(inclusionProof), - ); - } - - private createMintReason(data: IMintReasonJson): Promise { - switch (data.type) { - case MintReasonType.TOKEN_SPLIT: - return this.createSplitMintReason(data as ISplitMintReasonJson); - default: - throw new Error(`Unsupported mint reason type: ${data.type}`); - } - } - - private async createSplitMintReason(data: ISplitMintReasonJson): Promise { - const proofs = new Map(); - for (const [coinId, proof] of data.proofs) { - proofs.set( - BigInt(coinId), - new SplitMintReasonProof( - MerkleTreePath.fromJSON(proof.aggregationPath), - MerkleSumTreePath.fromJSON(proof.coinTreePath), - ), - ); - } - - return new SplitMintReason(await this.tokenDeserializer.deserialize(data.token), proofs); - } -} diff --git a/src/serializer/transaction/TransactionCborDeserializer.ts b/src/serializer/transaction/TransactionCborDeserializer.ts deleted file mode 100644 index cb9c12bf..00000000 --- a/src/serializer/transaction/TransactionCborDeserializer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; -import { CborDecoder } from '@unicitylabs/commons/lib/cbor/CborDecoder.js'; -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; - -import { IPredicateFactory } from '../../predicate/IPredicateFactory.js'; -import { TokenId } from '../../token/TokenId.js'; -import { TokenState } from '../../token/TokenState.js'; -import { TokenType } from '../../token/TokenType.js'; -import { Transaction } from '../../transaction/Transaction.js'; -import { TransactionData } from '../../transaction/TransactionData.js'; - -export class TransactionCborDeserializer { - public constructor(private readonly predicateFactory: IPredicateFactory) {} - - public async deserialize( - tokenId: TokenId, - tokenType: TokenType, - bytes: Uint8Array, - ): Promise> { - const transaction = CborDecoder.readArray(bytes); - const data = CborDecoder.readArray(transaction[0]); - const state = CborDecoder.readArray(data[0]); - - return new Transaction( - await TransactionData.create( - await TokenState.create( - await this.predicateFactory.create(tokenId, tokenType, state[0]), - CborDecoder.readOptional(state[1], CborDecoder.readByteString), - ), - CborDecoder.readTextString(data[1]), - CborDecoder.readByteString(data[2]), - CborDecoder.readOptional(data[3], DataHash.fromCBOR), - CborDecoder.readOptional(data[4], CborDecoder.readByteString), - [], - ), - InclusionProof.fromCBOR(transaction[1]), - ); - } -} diff --git a/src/serializer/transaction/TransactionJsonDeserializer.ts b/src/serializer/transaction/TransactionJsonDeserializer.ts deleted file mode 100644 index 43684455..00000000 --- a/src/serializer/transaction/TransactionJsonDeserializer.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; -import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; - -import { IPredicateFactory } from '../../predicate/IPredicateFactory.js'; -import { TokenId } from '../../token/TokenId.js'; -import { TokenState } from '../../token/TokenState.js'; -import { TokenType } from '../../token/TokenType.js'; -import { ITransactionJson, Transaction } from '../../transaction/Transaction.js'; -import { ITransactionDataJson, TransactionData } from '../../transaction/TransactionData.js'; - -export class TransactionJsonDeserializer { - public constructor(private readonly predicateFactory: IPredicateFactory) {} - - public async deserialize( - tokenId: TokenId, - tokenType: TokenType, - { data, inclusionProof }: ITransactionJson, - ): Promise> { - return new Transaction( - await TransactionData.create( - await TokenState.create( - await this.predicateFactory.create(tokenId, tokenType, data.sourceState.unlockPredicate), - data.sourceState.data ? HexConverter.decode(data.sourceState.data) : null, - ), - data.recipient, - HexConverter.decode(data.salt), - data.dataHash ? DataHash.fromJSON(data.dataHash) : null, - data.message ? HexConverter.decode(data.message) : null, - [], //await Promise.all(data.nameTags.map((input) => this.importToken(input, NameTagTokenData, predicateFactory))), - ), - InclusionProof.fromJSON(inclusionProof), - ); - } -} diff --git a/src/token/Token.ts b/src/token/Token.ts index a72b0a31..4cff65f5 100644 --- a/src/token/Token.ts +++ b/src/token/Token.ts @@ -1,30 +1,20 @@ -import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; import { NameTagToken } from './NameTagToken.js'; import { TokenId } from './TokenId.js'; -import { ITokenStateJson, TokenState } from './TokenState.js'; +import { TokenState } from './TokenState.js'; import { TokenType } from './TokenType.js'; import { ISerializable } from '../ISerializable.js'; -import { ITransactionJson, Transaction } from '../transaction/Transaction.js'; +import { TokenCborSerializer } from '../serializer/cbor/token/TokenCborSerializer.js'; +import { Transaction } from '../transaction/Transaction.js'; import { TokenCoinData } from './fungible/TokenCoinData.js'; -import { IMintTransactionDataJson, MintTransactionData } from '../transaction/MintTransactionData.js'; -import { ITransactionDataJson, TransactionData } from '../transaction/TransactionData.js'; +import { ITokenJson, TokenJsonSerializer } from '../serializer/json/token/TokenJsonSerializer.js'; +import { MintTransactionData } from '../transaction/MintTransactionData.js'; +import { TransactionData } from '../transaction/TransactionData.js'; /** Current serialization version for tokens. */ export const TOKEN_VERSION = '2.0'; -/** - * JSON representation of a {@link Token}. - */ -export interface ITokenJson { - readonly version: string; - readonly state: ITokenStateJson; - readonly genesis: ITransactionJson; - readonly transactions: ITransactionJson[]; - readonly nametagTokens: ITokenJson[]; -} - /** * In-memory representation of a token including its transaction history. */ @@ -79,24 +69,12 @@ export class Token transaction.toJSON()), - version: this.version, - }; + return TokenJsonSerializer.serialize(this); } /** Serialize this token to CBOR. */ public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([ - CborEncoder.encodeTextString(this.version), - this.genesis.toCBOR(), - CborEncoder.encodeArray(this.transactions.map((transaction) => transaction.toCBOR())), - this.state.toCBOR(), - CborEncoder.encodeArray(this.nametagTokens.map((token) => token.toCBOR())), - ]); + return TokenCborSerializer.serialize(this); } /** Convert instance to readable string */ diff --git a/src/token/TokenFactory.ts b/src/token/TokenFactory.ts index d2af5257..3a1c7dc1 100644 --- a/src/token/TokenFactory.ts +++ b/src/token/TokenFactory.ts @@ -10,7 +10,7 @@ import { MINTER_SECRET } from '../StateTransitionClient.js'; import { Token } from './Token.js'; import { BurnPredicate } from '../predicate/BurnPredicate.js'; import { PredicateType } from '../predicate/PredicateType.js'; -import { ITokenDeserializer } from '../serializer/token/ITokenDeserializer.js'; +import { ITokenSerializer } from '../serializer/token/ITokenSerializer.js'; import { MintTransactionData } from '../transaction/MintTransactionData.js'; import { Transaction } from '../transaction/Transaction.js'; import { TransactionData } from '../transaction/TransactionData.js'; @@ -23,7 +23,7 @@ export class TokenFactory { /** * @param deserializer token deserializer to use for parsing tokens from CBOR or JSON */ - public constructor(private readonly deserializer: ITokenDeserializer) {} + public constructor(private readonly deserializer: ITokenSerializer) {} /** * Deserialize a token from JSON. diff --git a/src/token/TokenState.ts b/src/token/TokenState.ts index 39b9ea3e..730cc1cd 100644 --- a/src/token/TokenState.ts +++ b/src/token/TokenState.ts @@ -5,13 +5,7 @@ import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js'; import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; -import { IPredicate, IPredicateJson } from '../predicate/IPredicate.js'; - -/** JSON representation of {@link TokenState}. */ -export interface ITokenStateJson { - readonly unlockPredicate: IPredicateJson; - readonly data: string | null; -} +import { IPredicate } from '../predicate/IPredicate.js'; /** * Represents a snapshot of token ownership and associated data. @@ -58,22 +52,6 @@ export class TokenState { ); } - /** Serialize the state to JSON. */ - public toJSON(): ITokenStateJson { - return { - data: this._data ? HexConverter.encode(this._data) : null, - unlockPredicate: this.unlockPredicate.toJSON(), - }; - } - - /** Encode the state as CBOR. */ - public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([ - this.unlockPredicate.toCBOR(), - CborEncoder.encodeOptional(this._data, CborEncoder.encodeByteString), - ]); - } - /** Convert instance to readable string */ public toString(): string { return dedent` diff --git a/src/token/fungible/SplitMintReason.ts b/src/token/fungible/SplitMintReason.ts index 9356ed5c..6ba06cd9 100644 --- a/src/token/fungible/SplitMintReason.ts +++ b/src/token/fungible/SplitMintReason.ts @@ -2,9 +2,10 @@ import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; import { BigintConverter } from '@unicitylabs/commons/lib/util/BigintConverter.js'; import { ISerializable } from '../../ISerializable.js'; +import { ITokenJson } from '../../serializer/json/token/TokenJsonSerializer.js'; import { MintTransactionData } from '../../transaction/MintTransactionData.js'; import { Transaction } from '../../transaction/Transaction.js'; -import { ITokenJson, Token } from '../Token.js'; +import { Token } from '../Token.js'; import { ISplitMintReasonProofJson, SplitMintReasonProof } from './SplitMintReasonProof.js'; import { MintReasonType } from '../../transaction/MintReasonType.js'; diff --git a/src/transaction/Commitment.ts b/src/transaction/Commitment.ts index 3aeae1d4..de2f943c 100644 --- a/src/transaction/Commitment.ts +++ b/src/transaction/Commitment.ts @@ -1,24 +1,50 @@ import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; +import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js'; import { ISerializable } from '../ISerializable.js'; import { MintTransactionData } from './MintTransactionData.js'; import { TransactionData } from './TransactionData.js'; /** - * Result returned when submitting a transaction to the aggregator. + * Represents a commitment to a transaction, including its request ID, transaction data, + * and an authenticator signature. + * + * @template T - The type of transaction data, which can be either `TransactionData` + * or `MintTransactionData` with an optional `ISerializable` payload. */ export class Commitment> { - private readonly _brand: string = 'Commitment'; - /** - * @param requestId Request identifier used for submission - * @param transactionData Submitted transaction data - * @param authenticator Signature over the payload + * Creates a new `Commitment` instance. + * + * @param {RequestId} requestId - The unique identifier for the transaction request. + * @param {T} transactionData - The data associated with the transaction. + * @param {Authenticator} authenticator - The signature over the transaction payload. */ public constructor( public readonly requestId: RequestId, public readonly transactionData: T, public readonly authenticator: Authenticator, ) {} + + /** + * Creates a new `Commitment` instance by generating a request ID and authenticator. + * + * @template T - The type of transaction data. + * @param {T} transactionData - The data associated with the transaction. + * @param {SigningService} signingService - The service used to sign the transaction. + * @returns {Promise>} A promise that resolves to a new `Commitment` instance. + */ + public static async create>( + transactionData: T, + signingService: SigningService, + ): Promise> { + const requestId = await RequestId.create(signingService.publicKey, transactionData.sourceState.hash); + const authenticator = await Authenticator.create( + signingService, + transactionData.hash, + transactionData.sourceState.hash, + ); + return new Commitment(requestId, transactionData, authenticator); + } } diff --git a/src/transaction/MintTransactionData.ts b/src/transaction/MintTransactionData.ts index 90d3cd48..a20f1ca5 100644 --- a/src/transaction/MintTransactionData.ts +++ b/src/transaction/MintTransactionData.ts @@ -7,7 +7,7 @@ import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; import { ISerializable } from '../ISerializable.js'; -import { TokenCoinData, TokenCoinDataJson } from '../token/fungible/TokenCoinData.js'; +import { TokenCoinData } from '../token/fungible/TokenCoinData.js'; import { TokenId } from '../token/TokenId.js'; import { TokenType } from '../token/TokenType.js'; @@ -17,22 +17,6 @@ import { TokenType } from '../token/TokenType.js'; */ const MINT_SUFFIX = HexConverter.decode('9e82002c144d7c5796c50f6db50a0c7bbd7f717ae3af6c6c71a3e9eba3022730'); -export interface IMintReasonJson { - readonly type: string; -} - -/** JSON representation of {@link MintTransactionData}. */ -export interface IMintTransactionDataJson { - readonly tokenId: string; - readonly tokenType: string; - readonly tokenData: string; - readonly coins: TokenCoinDataJson | null; - readonly recipient: string; - readonly salt: string; - readonly dataHash: string | null; - readonly reason: unknown | null; -} - /** * Data object describing a token mint operation. */ @@ -130,34 +114,6 @@ export class MintTransactionData { ); } - /** Serialize this object to JSON object. */ - public toJSON(): IMintTransactionDataJson { - return { - coins: this.coinData?.toJSON() ?? null, - dataHash: this.dataHash?.toJSON() ?? null, - reason: this.reason?.toJSON() ?? null, - recipient: this.recipient, - salt: HexConverter.encode(this._salt), - tokenData: HexConverter.encode(this._tokenData), - tokenId: this.tokenId.toJSON(), - tokenType: this.tokenType.toJSON(), - }; - } - - /** Serialize this object to CBOR. */ - public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([ - this.tokenId.toCBOR(), - this.tokenType.toCBOR(), - CborEncoder.encodeByteString(this._tokenData), - this.coinData?.toCBOR() ?? CborEncoder.encodeNull(), - CborEncoder.encodeTextString(this.recipient), - CborEncoder.encodeByteString(this._salt), - this.dataHash?.toCBOR() ?? CborEncoder.encodeNull(), - this.reason?.toCBOR() ?? CborEncoder.encodeNull(), - ]); - } - /** Convert instance to readable string */ public toString(): string { return dedent` diff --git a/src/transaction/OfflineCommitment.ts b/src/transaction/OfflineCommitment.ts deleted file mode 100644 index fcb69990..00000000 --- a/src/transaction/OfflineCommitment.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; -import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; - -import { TransactionData } from './TransactionData.js'; - -/** - * Result returned when submitting a transaction to the aggregator. - */ -export class OfflineCommitment { - private readonly _brand: string = 'OfflineCommitment'; - - /** - * @param requestId Request identifier used for submission - * @param transactionData Submitted transaction data - * @param authenticator Signature over the payload - */ - public constructor( - public readonly requestId: RequestId, - public readonly transactionData: TransactionData, - public readonly authenticator: Authenticator, - ) {} -} diff --git a/src/transaction/OfflineTransaction.ts b/src/transaction/OfflineTransaction.ts deleted file mode 100644 index 492ce979..00000000 --- a/src/transaction/OfflineTransaction.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Authenticator } from '@unicitylabs/commons/lib/api/Authenticator.js'; -import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js'; -import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; -import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; - -import { MintTransactionData } from './MintTransactionData.js'; -import { OfflineCommitment } from './OfflineCommitment.js'; -import { Transaction } from './Transaction.js'; -import { ITransactionDataJson, TransactionData } from './TransactionData.js'; -import { ISerializable } from '../ISerializable.js'; -import { PredicateJsonFactory } from '../predicate/PredicateJsonFactory.js'; -import { TokenJsonDeserializer } from '../serializer/token/TokenJsonDeserializer.js'; -import { ITokenJson, Token } from '../token/Token.js'; -import { TokenFactory } from '../token/TokenFactory.js'; -import { TokenState } from '../token/TokenState.js'; - -/** JSON representation of an {@link OfflineTransaction}. */ -export interface IOfflineTransactionJson { - readonly commitment: { - readonly requestId: string; - readonly transactionData: ITransactionDataJson; - readonly authenticator: unknown; - }; - readonly token: ITokenJson; -} - -/** - * Represents a transaction with its commitment for offline processing. - */ -export class OfflineTransaction implements ISerializable { - /** - * @param commitment The commitment for the transaction - * @param token - */ - public constructor( - public readonly commitment: OfflineCommitment, - public readonly token: Token>>, - ) {} - - /** - * Create OfflineTransaction from JSON data. - * This properly deserializes all components using the necessary factories. - * @param data JSON data - */ - public static async fromJSON(data: unknown): Promise { - if (!OfflineTransaction.isJSON(data)) { - throw new Error('Invalid offline transaction JSON format'); - } - - // Initialize the necessary factories - const predicateFactory = new PredicateJsonFactory(); - const tokenFactory = new TokenFactory(new TokenJsonDeserializer(predicateFactory)); - - // Deserialize the token from JSON - const token = await tokenFactory.create(data.token); - - // Reconstruct the commitment components - const requestId = RequestId.fromJSON(data.commitment.requestId); - const authenticator = Authenticator.fromJSON(data.commitment.authenticator); - - // Reconstruct the transaction data - const txData = data.commitment.transactionData; - const transactionData = await TransactionData.create( - await TokenState.create( - await predicateFactory.create(token.id, token.type, txData.sourceState.unlockPredicate), - txData.sourceState.data ? HexConverter.decode(txData.sourceState.data) : null, - ), - txData.recipient, - HexConverter.decode(txData.salt), - txData.dataHash ? DataHash.fromJSON(txData.dataHash) : null, - txData.message ? HexConverter.decode(txData.message) : null, - [], // nameTags - TODO: deserialize if needed - ); - - // Create the OfflineCommitment - const offlineCommitment = new OfflineCommitment(requestId, transactionData, authenticator); - - return new OfflineTransaction(offlineCommitment, token); - } - - /** - * Type guard to check if data is valid OfflineTransaction JSON. - * @param data Data to validate - */ - private static isJSON(data: unknown): data is IOfflineTransactionJson { - return ( - typeof data === 'object' && - data !== null && - 'commitment' in data && - 'token' in data && - typeof data.commitment === 'object' && - typeof data.token === 'object' - ); - } - - /** Serialize to CBOR format */ - public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([ - CborEncoder.encodeArray([ - this.commitment.requestId.toCBOR(), - this.commitment.transactionData.toCBOR(), - this.commitment.authenticator.toCBOR(), - ]), - this.token.toCBOR(), - ]); - } - - /** Serialize to JSON format */ - public toJSON(): IOfflineTransactionJson { - return { - commitment: { - authenticator: this.commitment.authenticator.toJSON(), - requestId: this.commitment.requestId.toJSON(), - transactionData: this.commitment.transactionData.toJSON(), - }, - token: this.token.toJSON(), - }; - } -} diff --git a/src/transaction/Transaction.ts b/src/transaction/Transaction.ts index b7d36e48..d02eeb30 100644 --- a/src/transaction/Transaction.ts +++ b/src/transaction/Transaction.ts @@ -1,25 +1,11 @@ -import type { IInclusionProofJson } from '@unicitylabs/commons/lib/api/InclusionProof.js'; import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; -import { CborEncoder } from '@unicitylabs/commons/lib/cbor/CborEncoder.js'; import { DataHasher } from '@unicitylabs/commons/lib/hash/DataHasher.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; -import { IMintTransactionDataJson, MintTransactionData } from './MintTransactionData.js'; -import { ITransactionDataJson, TransactionData } from './TransactionData.js'; +import { MintTransactionData } from './MintTransactionData.js'; +import { TransactionData } from './TransactionData.js'; import { ISerializable } from '../ISerializable.js'; -type TransactionDataType = T extends TransactionData - ? ITransactionDataJson - : T extends MintTransactionData - ? IMintTransactionDataJson - : never; - -/** JSON representation of a {@link Transaction}. */ -export interface ITransactionJson { - readonly data: T; - readonly inclusionProof: IInclusionProofJson; -} - /** * A transaction along with its verified inclusion proof. */ @@ -33,19 +19,6 @@ export class Transaction> { - return { - data: this.data.toJSON() as TransactionDataType, - inclusionProof: this.inclusionProof.toJSON(), - }; - } - - /** Serialize transaction and proof to CBOR. */ - public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([this.data.toCBOR(), this.inclusionProof.toCBOR()]); - } - /** * Verify if the provided data matches the optional data hash. * @param data Data to verify against the transaction's data hash diff --git a/src/transaction/TransactionData.ts b/src/transaction/TransactionData.ts index 91937286..17b1e305 100644 --- a/src/transaction/TransactionData.ts +++ b/src/transaction/TransactionData.ts @@ -6,43 +6,37 @@ import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js'; import { dedent } from '@unicitylabs/commons/lib/util/StringUtils.js'; import { NameTagToken } from '../token/NameTagToken.js'; -import { ITokenJson } from '../token/Token.js'; -import { ITokenStateJson, TokenState } from '../token/TokenState.js'; - -/** JSON representation of a {@link TransactionData}. */ -export interface ITransactionDataJson { - readonly sourceState: ITokenStateJson; - readonly recipient: string; - readonly salt: string; - readonly dataHash: string | null; - readonly message: string | null; - readonly nameTags: ITokenJson[]; -} +import { TokenState } from '../token/TokenState.js'; /** * Data describing a standard token transfer. */ export class TransactionData { /** - * @param hash Hash of the encoded data - * @param sourceState Previous token state - * @param recipient Address of the new owner - * @param salt Salt used in the new predicate - * @param dataHash Optional additional data hash - * @param _message Optional message bytes - * @param nameTags Optional name tag tokens + * @param hash Hash of the encoded data + * @param sourceState Previous token state + * @param recipient Address of the new owner + * @param _salt Salt for current transaction + * @param dataHash Optional additional data hash + * @param _message Optional message bytes + * @param _nametagTokens Optional name tag tokens */ private constructor( public readonly hash: DataHash, public readonly sourceState: TokenState, public readonly recipient: string, - public readonly salt: Uint8Array, + private readonly _salt: Uint8Array, public readonly dataHash: DataHash | null, private readonly _message: Uint8Array | null, - private readonly nameTags: NameTagToken[] = [], + private readonly _nametagTokens: NameTagToken[] = [], ) { this._message = _message ? new Uint8Array(_message) : null; - this.nameTags = Array.from(nameTags); + this._nametagTokens = Array.from(_nametagTokens); + } + + /** Salt used for the transaction. */ + public get salt(): Uint8Array { + return new Uint8Array(this._salt); } /** Optional message attached to the transfer. */ @@ -55,6 +49,11 @@ export class TransactionData { return this.hash.algorithm; } + /** Nametag tokens associated with this transaction. */ + public get nametagTokens(): NameTagToken[] { + return this._nametagTokens.slice(); + } + /** * Create a new transaction data object. * @param state Token state used as source for the transfer @@ -93,41 +92,17 @@ export class TransactionData { ); } - /** Serialize this token to JSON. */ - public toJSON(): ITransactionDataJson { - return { - dataHash: this.dataHash?.toJSON() ?? null, - message: this._message ? HexConverter.encode(this._message) : null, - nameTags: this.nameTags.map((token) => token.toJSON()), - recipient: this.recipient, - salt: HexConverter.encode(this.salt), - sourceState: this.sourceState.toJSON(), - }; - } - - /** Serialize this token to CBOR. */ - public toCBOR(): Uint8Array { - return CborEncoder.encodeArray([ - this.sourceState.toCBOR(), - CborEncoder.encodeTextString(this.recipient), - CborEncoder.encodeByteString(this.salt), - this.dataHash?.toCBOR() ?? CborEncoder.encodeNull(), - this._message ? CborEncoder.encodeByteString(this._message) : CborEncoder.encodeNull(), - CborEncoder.encodeArray(this.nameTags.map((token) => token.toCBOR())), - ]); - } - /** Convert instance to readable string */ public toString(): string { return dedent` TransactionData: ${this.sourceState.toString()} Recipient: ${this.recipient.toString()} - Salt: ${HexConverter.encode(this.salt)} + Salt: ${HexConverter.encode(this._salt)} Data: ${this.dataHash?.toString() ?? null} Message: ${this._message ? HexConverter.encode(this._message) : null} NameTags: [ - ${this.nameTags.map((token) => token.toString()).join('\n')} + ${this._nametagTokens.map((token) => token.toString()).join('\n')} ] Hash: ${this.hash.toString()}`; } diff --git a/tests/MintTokenUtils.ts b/tests/MintTokenUtils.ts index b3c817e1..38cd5b63 100644 --- a/tests/MintTokenUtils.ts +++ b/tests/MintTokenUtils.ts @@ -1,3 +1,4 @@ +import { SubmitCommitmentStatus } from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js'; import { DataHasher } from '@unicitylabs/commons/lib/hash/DataHasher.js'; import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js'; import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js'; @@ -12,6 +13,7 @@ import { Token } from '../src/token/Token.js'; import { TokenId } from '../src/token/TokenId.js'; import { TokenState } from '../src/token/TokenState.js'; import { TokenType } from '../src/token/TokenType.js'; +import { Commitment } from '../src/transaction/Commitment.js'; import { MintTransactionData } from '../src/transaction/MintTransactionData.js'; import { Transaction } from '../src/transaction/Transaction.js'; import { TransactionData } from '../src/transaction/TransactionData.js'; @@ -127,6 +129,11 @@ export async function sendToken( token.nametagTokens, ); - const commitment = await client.submitTransaction(transactionData, signingService); - return await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); + const commitment = await Commitment.create(transactionData, signingService); + const response = await client.submitCommitment(commitment); + if (response.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Failed to submit transaction commitment: ${response.status}`); + } + + return client.createTransaction(commitment, await waitInclusionProof(client, commitment)); } diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index f843bcfe..44087414 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -1,4 +1,5 @@ import { InclusionProofVerificationStatus } from '@unicitylabs/commons/lib/api/InclusionProof.js'; +import { SubmitCommitmentStatus } from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js'; import { DataHasher } from '@unicitylabs/commons/lib/hash/DataHasher.js'; import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js'; @@ -7,12 +8,12 @@ import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService. import { DirectAddress } from '../../src/address/DirectAddress.js'; import { ISerializable } from '../../src/ISerializable.js'; -import { OfflineStateTransitionClient } from '../../src/OfflineStateTransitionClient.js'; import { BurnPredicate } from '../../src/predicate/BurnPredicate.js'; import { MaskedPredicate } from '../../src/predicate/MaskedPredicate.js'; import { PredicateJsonFactory } from '../../src/predicate/PredicateJsonFactory.js'; -import { TokenJsonDeserializer } from '../../src/serializer/token/TokenJsonDeserializer.js'; -import { TransactionJsonDeserializer } from '../../src/serializer/transaction/TransactionJsonDeserializer.js'; +import { TokenJsonSerializer } from '../../src/serializer/json/token/TokenJsonSerializer.js'; +import { CommitmentJsonSerializer } from '../../src/serializer/json/transaction/CommitmentJsonSerializer.js'; +import { TransactionJsonSerializer } from '../../src/serializer/json/transaction/TransactionJsonSerializer.js'; import { StateTransitionClient } from '../../src/StateTransitionClient.js'; import { CoinId } from '../../src/token/fungible/CoinId.js'; import { TokenCoinData } from '../../src/token/fungible/TokenCoinData.js'; @@ -21,12 +22,11 @@ import { TokenFactory } from '../../src/token/TokenFactory.js'; import { TokenId } from '../../src/token/TokenId.js'; import { TokenState } from '../../src/token/TokenState.js'; import { TokenType } from '../../src/token/TokenType.js'; +import { Commitment } from '../../src/transaction/Commitment.js'; import { MintTransactionData } from '../../src/transaction/MintTransactionData.js'; -import { OfflineCommitment } from '../../src/transaction/OfflineCommitment.js'; -import { OfflineTransaction } from '../../src/transaction/OfflineTransaction.js'; import { TokenSplitBuilder } from '../../src/transaction/split/TokenSplitBuilder.js'; -import { ITransactionJson, Transaction } from '../../src/transaction/Transaction.js'; -import { ITransactionDataJson, TransactionData } from '../../src/transaction/TransactionData.js'; +import { Transaction } from '../../src/transaction/Transaction.js'; +import { TransactionData } from '../../src/transaction/TransactionData.js'; import { waitInclusionProof } from '../../src/utils/InclusionProofUtils.js'; import { createMintData, mintToken, sendToken } from '../MintTokenUtils.js'; @@ -34,8 +34,8 @@ const textEncoder = new TextEncoder(); const initialOwnerSecret = textEncoder.encode('secret'); const receiverSecret = textEncoder.encode('tere'); const predicateFactory = new PredicateJsonFactory(); -const tokenFactory = new TokenFactory(new TokenJsonDeserializer(predicateFactory)); -const transactionDeserializer = new TransactionJsonDeserializer(predicateFactory); +const tokenFactory = new TokenFactory(new TokenJsonSerializer(predicateFactory)); +const transactionDeserializer = new TransactionJsonSerializer(predicateFactory); async function createMintToken( client: StateTransitionClient, @@ -104,7 +104,7 @@ export async function testTransferFlow(client: StateTransitionClient): Promise, + TransactionJsonSerializer.serialize(transaction), ); // Finish the transaction with the recipient predicate @@ -163,44 +163,43 @@ export async function testOfflineTransferFlow(client: StateTransitionClient): Pr nonce, ); - let offlineTxPackage: OfflineTransaction; - const offlineBuilder = new OfflineStateTransitionClient(client.client); + const receivingAddress = await DirectAddress.create(recipientPredicate.reference); - { - const receivingAddress = await DirectAddress.create(recipientPredicate.reference); - - const transactionData = await TransactionData.create( - token.state, - receivingAddress.toJSON(), - crypto.getRandomValues(new Uint8Array(32)), - await new DataHasher(HashAlgorithm.SHA256).update(textEncoder.encode('my custom data')).digest(), - textEncoder.encode('my message'), - token.nametagTokens, - ); - - // sender signs the transaction data - const offlineCommitment: OfflineCommitment = await offlineBuilder.createOfflineCommitment( - transactionData, - firstOwnerSigningService, - ); + const transactionData = await TransactionData.create( + token.state, + receivingAddress.toJSON(), + crypto.getRandomValues(new Uint8Array(32)), + await new DataHasher(HashAlgorithm.SHA256).update(textEncoder.encode('my custom data')).digest(), + textEncoder.encode('my message'), + token.nametagTokens, + ); - const offlineTransaction = new OfflineTransaction(offlineCommitment, token); + const commitment = await Commitment.create(transactionData, firstOwnerSigningService); - // Test the full JSON serialization cycle that would happen in real usage - // 1. Get JSON representation of the offline transaction - const offlineTxJson = JSON.stringify(offlineTransaction.toJSON()); + // Test the full JSON serialization cycle that would happen in real usage + // 1. Get JSON representation of the offline transaction + const offlineTxJson = JSON.stringify({ commitment: CommitmentJsonSerializer.serialize(commitment), token }); - // 2. Simulate transfer and parsing (what recipient would do) - const parsedJson = JSON.parse(offlineTxJson); + // 2. Simulate transfer and parsing (what recipient would do) + const parsedJson = JSON.parse(offlineTxJson); - // 3. Deserialize back to object - //...sender sends the "package" offline to the recipient - offlineTxPackage = await OfflineTransaction.fromJSON(parsedJson); - } + // 3. Deserialize back to object + //...sender sends the "package" offline to the recipient + const importedToken = await tokenFactory.create(parsedJson.token); + const importedCommitment = await new CommitmentJsonSerializer(predicateFactory).deserialize( + importedToken.id, + importedToken.type, + parsedJson.commitment, + ); // Recipient imports token (offline json file transfer) - const importedToken = offlineTxPackage.token; - const confirmedTx = await offlineBuilder.submitOfflineTransaction(offlineTxPackage.commitment); + const response = await client.submitCommitment(importedCommitment); + expect(response.status).toEqual(SubmitCommitmentStatus.SUCCESS); + + const confirmedTx = await client.createTransaction( + importedCommitment, + await waitInclusionProof(client, importedCommitment), + ); // Finish the transaction with the recipient predicate const updateToken = await client.finishTransaction( @@ -326,7 +325,10 @@ export async function testSplitFlowAfterTransfer(client: StateTransitionClient): ); //sender export token with transfer transaction - const tokenJson = JSON.stringify({ token: splitTokens[0], transaction: sendTokenTx }); + const tokenJson = JSON.stringify({ + token: splitTokens[0], + transaction: TransactionJsonSerializer.serialize(sendTokenTx), + }); // Recipient imports token and transaction const receiverImportedToken = await tokenFactory.create(JSON.parse(tokenJson).token); @@ -334,7 +336,7 @@ export async function testSplitFlowAfterTransfer(client: StateTransitionClient): const importedTransaction = await transactionDeserializer.deserialize( receiverImportedToken.id, receiverImportedToken.type, - JSON.parse(tokenJson).transaction as ITransactionJson, + JSON.parse(tokenJson).transaction, ); const maskedPredicate = await MaskedPredicate.create( @@ -425,7 +427,8 @@ async function splitToken( crypto.getRandomValues(new Uint8Array(32)), splitData.rootHash, ); - const commitment = await client.submitTransaction( + + const commitment = await Commitment.create( await TransactionData.create( token.state, (await DirectAddress.create(burnPredicate.reference)).toString(), @@ -435,6 +438,8 @@ async function splitToken( ), await SigningService.createFromSecret(ownerSecret, nonce), ); + const response = await client.submitCommitment(commitment); + expect(response.status).toEqual(SubmitCommitmentStatus.SUCCESS); const transaction = await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); const updatedToken = await client.finishTransaction( From f814d3c3ff96b64960d792f1efead72dd50f0a7b Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Mon, 30 Jun 2025 16:18:57 +0300 Subject: [PATCH 06/10] Add token serializer test --- src/predicate/DefaultPredicate.ts | 2 +- src/predicate/MaskedPredicate.ts | 4 +- src/predicate/UnmaskedPredicate.ts | 4 +- .../MintTransactionDataCborSerializer.ts | 2 +- .../serializer/token/TokenSerializerTest.ts | 102 ++++++++++++++++++ 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 tests/unit/serializer/token/TokenSerializerTest.ts diff --git a/src/predicate/DefaultPredicate.ts b/src/predicate/DefaultPredicate.ts index 2e2bc13a..ea662fff 100644 --- a/src/predicate/DefaultPredicate.ts +++ b/src/predicate/DefaultPredicate.ts @@ -99,7 +99,7 @@ export abstract class DefaultPredicate implements IPredicate { CborEncoder.encodeTextString(this.type), CborEncoder.encodeByteString(this.publicKey), CborEncoder.encodeTextString(this.algorithm), - CborEncoder.encodeTextString(HashAlgorithm[this.hashAlgorithm]), + CborEncoder.encodeUnsignedInteger(this.hashAlgorithm), CborEncoder.encodeByteString(this.nonce), ]); } diff --git a/src/predicate/MaskedPredicate.ts b/src/predicate/MaskedPredicate.ts index fc2e65fe..8dcb9579 100644 --- a/src/predicate/MaskedPredicate.ts +++ b/src/predicate/MaskedPredicate.ts @@ -110,8 +110,8 @@ export class MaskedPredicate extends DefaultPredicate { throw new Error(`Invalid predicate type: expected ${PredicateType.MASKED}, got ${type}`); } - const hashAlgorithm = CborDecoder.readTextString(data[3]) as unknown as HashAlgorithm; - if (!HashAlgorithm[hashAlgorithm]) { + const hashAlgorithm = Number(CborDecoder.readUnsignedInteger(data[3])); + if (HashAlgorithm[hashAlgorithm] == null) { throw new Error(`Invalid hash algorithm: ${hashAlgorithm}`); } diff --git a/src/predicate/UnmaskedPredicate.ts b/src/predicate/UnmaskedPredicate.ts index 7cefc279..b228e449 100644 --- a/src/predicate/UnmaskedPredicate.ts +++ b/src/predicate/UnmaskedPredicate.ts @@ -107,8 +107,8 @@ export class UnmaskedPredicate extends DefaultPredicate { throw new Error(`Invalid predicate type: expected ${PredicateType.UNMASKED}, got ${type}`); } - const hashAlgorithm = CborDecoder.readTextString(data[3]) as unknown as HashAlgorithm; - if (!HashAlgorithm[hashAlgorithm]) { + const hashAlgorithm = Number(CborDecoder.readUnsignedInteger(data[3])); + if (HashAlgorithm[hashAlgorithm] == null) { throw new Error(`Invalid hash algorithm: ${hashAlgorithm}`); } diff --git a/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts b/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts index 14348f0d..2f862465 100644 --- a/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts +++ b/src/serializer/cbor/transaction/MintTransactionDataCborSerializer.ts @@ -59,7 +59,7 @@ export class MintTransactionDataCborSerializer { CborDecoder.readTextString(data[4]), CborDecoder.readByteString(data[5]), CborDecoder.readOptional(data[6], DataHash.fromCBOR), - data[7] ? await this.createMintReason(data[7]) : null, + await CborDecoder.readOptional(data[7], this.createMintReason), ); } diff --git a/tests/unit/serializer/token/TokenSerializerTest.ts b/tests/unit/serializer/token/TokenSerializerTest.ts new file mode 100644 index 00000000..41f168e7 --- /dev/null +++ b/tests/unit/serializer/token/TokenSerializerTest.ts @@ -0,0 +1,102 @@ +import { InclusionProof } from '@unicitylabs/commons/lib/api/InclusionProof.js'; +import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { DataHasher } from '@unicitylabs/commons/lib/hash/DataHasher.js'; +import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js'; +import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js'; +import { MerkleTreePath } from '@unicitylabs/commons/lib/smt/MerkleTreePath.js'; + +import { DirectAddress } from '../../../../src/address/DirectAddress.js'; +import { MaskedPredicate } from '../../../../src/predicate/MaskedPredicate.js'; +import { PredicateCborFactory } from '../../../../src/predicate/PredicateCborFactory.js'; +import { PredicateJsonFactory } from '../../../../src/predicate/PredicateJsonFactory.js'; +import { TokenCborSerializer } from '../../../../src/serializer/cbor/token/TokenCborSerializer.js'; +import { TokenJsonSerializer } from '../../../../src/serializer/json/token/TokenJsonSerializer.js'; +import { Token, TOKEN_VERSION } from '../../../../src/token/Token.js'; +import { TokenId } from '../../../../src/token/TokenId.js'; +import { TokenState } from '../../../../src/token/TokenState.js'; +import { TokenType } from '../../../../src/token/TokenType.js'; +import { MintTransactionData } from '../../../../src/transaction/MintTransactionData.js'; +import { Transaction } from '../../../../src/transaction/Transaction.js'; +import { TransactionData } from '../../../../src/transaction/TransactionData.js'; + +describe('TokenSerializers', () => { + const textEncoder = new TextEncoder(); + + it('serializes and deserializes a token correctly', async () => { + const signingService = new SigningService(crypto.getRandomValues(new Uint8Array(32))); + + const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(64))); + const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(64))); + const initialState = await TokenState.create( + await MaskedPredicate.create( + tokenId, + tokenType, + signingService, + HashAlgorithm.SHA256, + crypto.getRandomValues(new Uint8Array(64)), + ), + textEncoder.encode('token state no. 1'), + ); + + const genesis = new Transaction( + await MintTransactionData.create( + tokenId, + tokenType, + textEncoder.encode('my custom initial data'), + null, + (await DirectAddress.create(initialState.unlockPredicate.reference)).toString(), + crypto.getRandomValues(new Uint8Array(64)), + await new DataHasher(HashAlgorithm.SHA512).update(initialState.data!).digest(), + null, + ), + new InclusionProof( + new MerkleTreePath(new DataHash(HashAlgorithm.RIPEMD160, crypto.getRandomValues(new Uint8Array(20))), []), + null, + null, + ), + ); + + const tokenState = await TokenState.create( + await MaskedPredicate.create( + tokenId, + tokenType, + signingService, + HashAlgorithm.SHA256, + crypto.getRandomValues(new Uint8Array(64)), + ), + textEncoder.encode('token state no. 2'), + ); + + const transaction = new Transaction( + await TransactionData.create( + initialState, + (await DirectAddress.create(tokenState.unlockPredicate.reference)).toString(), + crypto.getRandomValues(new Uint8Array(64)), + await new DataHasher(HashAlgorithm.SHA512).update(tokenState.data!).digest(), + null, + ), + new InclusionProof( + new MerkleTreePath(new DataHash(HashAlgorithm.RIPEMD160, crypto.getRandomValues(new Uint8Array(20))), []), + null, + null, + ), + ); + + const token = new Token(initialState, genesis, [transaction]); + const tokens = await Promise.all([ + new TokenJsonSerializer(new PredicateJsonFactory()).deserialize(JSON.parse(JSON.stringify(token))), + new TokenCborSerializer(new PredicateCborFactory()).deserialize(token.toCBOR()), + ]); + for (const token of tokens) { + expect(token.version).toEqual(TOKEN_VERSION); + expect(token.id).toEqual(tokenId); + expect(token.type).toEqual(tokenType); + expect(token.data).toEqual(textEncoder.encode('my custom initial data')); + expect(token.coins).toBeFalsy(); + expect(token.nametagTokens.length).toEqual(0); + expect(token.genesis.inclusionProof).toEqual(genesis.inclusionProof); + expect(token.transactions.length).toEqual(1); + expect(token.transactions[0]).toEqual(transaction); + } + }); +}); From 68d86b87d241d560f457830a3d53d1b80ee84d66 Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Tue, 1 Jul 2025 20:34:56 +0300 Subject: [PATCH 07/10] Fix transfer flow test --- tests/token/CommonTestFlow.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index 44087414..67f70434 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -81,11 +81,11 @@ export async function testTransferFlow(client: StateTransitionClient): Promise Date: Thu, 3 Jul 2025 18:00:17 +0300 Subject: [PATCH 08/10] Update Readme --- README.md | 704 ++++++++------------- src/transaction/split/SplitResult.ts | 29 +- src/transaction/split/SplitToken.ts | 7 +- src/transaction/split/SplitTokenBuilder.ts | 12 +- src/transaction/split/TokenSplitBuilder.ts | 11 +- tests/MintTokenUtils.ts | 28 - tests/token/CommonTestFlow.ts | 37 +- 7 files changed, 336 insertions(+), 492 deletions(-) diff --git a/README.md b/README.md index aee518fa..10241fd0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ const client = new StateTransitionClient(aggregatorClient); const secret = crypto.getRandomValues(new Uint8Array(128)); // User secret key const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); // Chosen ID const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); // Token type -const tokenData = new TestTokenData(); /* Your own token data object with ISerializable attributes */ +const tokenData = new Uint8Array(0); /* Your own token data object with ISerializable attributes */ const coinData = new TokenCoinData([/* [CoinId, value] elements to have coins in token */]); const salt = crypto.getRandomValues(new Uint8Array(32)); /* Your random salt bytes */ const stateData = new Uint8Array()/* Your state data bytes */; @@ -47,58 +47,73 @@ const stateData = new Uint8Array()/* Your state data bytes */; const nonce = crypto.getRandomValues(new Uint8Array(32)); /* Your random nonce bytes */ const signingService = await SigningService.createFromSecret(secret, nonce); const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); +const recipient = await DirectAddress.create(data.predicate.reference); const commitment = await client.submitMintTransaction( - await DirectAddress.create(predicate.reference), - tokenId, - tokenType, - tokenData, - coinData, - salt, - await new DataHasher(HashAlgorithm.SHA256).update(stateData).digest(), - null, + await MintTransactionData.create( + tokenId, + tokenType, + tokenData, + coinData, + recipient.toString(), + data.salt, + await new DataHasher(HashAlgorithm.SHA256).update(data.data).digest(), + null, + ), ); + // Since submit takes time, inclusion proof might not be immediately available const inclusionProof = await client.getInclusionProof(commitment); const mintTransaction = await client.createTransaction(commitment, inclusionProof); -// Create token from transaction -const token = new Token( - tokenId, - tokenType, - tokenData, - coinData, - await TokenState.create(predicate, stateData), - [mintTransaction], -); +const token = new Token(await TokenState.create(data.predicate, data.data), mintTransaction, []); ``` Transfer + ```typescript +const textEncoder = new TextEncoder(); + // Create aggregator client const aggregatorClient = new AggregatorClient('https://gateway-test.unicity.network'); const client = new StateTransitionClient(aggregatorClient); -// Transfer token to recipient -const commitment = await client.submitTransaction(/* transfer parameters */); -// Since submit takes time, inclusion proof might not be immediately available -const inclusionProof = await client.getInclusionProof(commitment); -const transaction = await client.createTransaction(commitment, inclusionProof); +// Assume you have a token object from previous minting +let token: Token; +// Sender secret +let secret: Uint8Array; -// Recipient takes transaction and finishes it -await client.finishTransaction(/* transaction parameters */); -``` +// Recipient address (obtained from recipient) +let recipient: string; +// Recipient +let recipientDataHash: DataHash; -### Browser Usage +// secret is the secret key of the sender +const signingService = await SigningService.createFromSecret(secret, token.state.unlockPredicate.nonce); +const transactionData = await TransactionData.create( + token.state, + recipient, + crypto.getRandomValues(new Uint8Array(32)), + recipientDataHash, + textEncoder.encode('user defined transaction message'), + token.nametagTokens, +); -When used in a browser environment, hashing automatically falls back to the Web -Crypto API. Use `createDefaultDataHasherFactory()` when constructing prefix hash -trees: +const commitment = await Commitment.create(transactionData, signingService); +const response = await client.submitCommitment(commitment); +if (response.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Failed to submit transaction commitment: ${response.status}`); +} -```typescript -import { createDefaultDataHasherFactory } from '@unicitylabs/state-transition-sdk'; +// Since submit takes time, inclusion proof might not be immediately available +const inclusionProof = await client.getInclusionProof(commitment); +const transaction = client.createTransaction(commitment, inclusionProof); -const hasherFactory = createDefaultDataHasherFactory(); +recipientToken = await client.finishTransaction( + token, + await TokenState.create(recipientPredicate, new TextEncoder().encode('my custom data')), + transaction, +); ``` ## Core Components @@ -107,19 +122,12 @@ const hasherFactory = createDefaultDataHasherFactory(); The main SDK interface for token operations: -- `submitMintTransaction()` - Create mint commitment -- `submitTransaction()` - Create transfer commitment -- `submitBurnTransactionForSplit()` - Create burn commitment as the first step of a token split (the next and final step is the mint operation) +- `submitMintTransaction()` - Create mint commitment and send to aggregator +- `submitCommitment()` - Submit transaction commitment to aggregator - `createTransaction()` - Create transactions from commitments - `finishTransaction()` - Complete token transfers - `getTokenStatus()` - Check token status via inclusion proofs - -### OfflineStateTransitionClient - -Extended client for offline transaction operations: - -- `createOfflineCommitment()` - Create offline commitment for a transaction (does not post to aggregator) -- `submitOfflineTransaction()` - Submit a previously created offline transaction commitment +- `getInclusionProof()` - Retrieve inclusion proof for a commitment ### Address System @@ -161,9 +169,12 @@ const maskedPredicate = await MaskedPredicate.create( ### Token Types **Fungible Tokens**: Standard value-bearing tokens + ```typescript +const textEncoder = new TextEncoder(); + const tokenData = new TokenCoinData([ - { coinId: CoinId.ALPHA_COIN, value: BigInt(1000) } + { coinId: new CoinId(textEncoder.encode('ALPHA_COIN')), value: BigInt(1000) } ]); ``` @@ -181,36 +192,37 @@ Recipient knows some info about token, like token type for generating address. ```text A[Start] A --> B[Recipient Generates Address] -B --> C[Recipient Shares Address with Sender] -C --> D[Sender Submits Transaction Commitment] -D --> E[Sender Retrieves Inclusion Proof] -E --> F[Sender Creates Transaction] -F --> G[Sender Sends Transaction and Token to Recipient] -G --> H[Recipient Imports Token and Transaction] -H --> I[Recipient Verifies Transaction] -I --> J[Recipient Finishes Transaction] -J --> K[End] +B --> C[Recipient Shares Address And New Data Hash with Sender] +C --> D[Sender Creates Transaction Commitment] +D --> E[Sender Submits Transaction Commitment] +E --> F[Sender Retrieves Inclusion Proof] +F --> G[Sender Creates Transaction] +G --> H[Sender Sends Transaction and Token to Recipient] +H --> I[Recipient Imports Token and Transaction] +I --> J[Recipient Verifies Transaction] +J --> K[Recipient Finishes Transaction] +K --> L[End] ``` #### Offline Transfer flow -For situations where immediate network connectivity isn't available, the SDK supports offline transaction creation: +For situations where immediate network connectivity isn't available: ```text A[Start] A --> B[Recipient Generates Address] -B --> C[Recipient Shares Address with Sender] -C --> D[Sender Creates Offline Commitment] -D --> E[Sender Serializes OfflineTransaction to JSON] -E --> F[Sender Transfers JSON File Offline to Recipient] -F --> G[Recipient Deserializes OfflineTransaction from JSON] -G --> H[Recipient Submits Offline Transaction to Network] -H --> I[Recipient Retrieves Inclusion Proof] -I --> J[Recipient Finishes Transaction] -J --> K[End] +B --> C[Recipient Shares Address And New Data Hash with Sender] +C --> D[Sender Creates Transaction Commitment] +D --> E[Recipient Submits Transaction Commitment] +E --> F[Recipient Retrieves Inclusion Proof] +F --> G[Recipient Creates Transaction] +G --> H[Recipient Sends Transaction and Token to Recipient] +H --> I[Recipient Imports Token and Transaction] +I --> J[Recipient Verifies Transaction] +J --> K[Recipient Finishes Transaction] +K --> L[End] ``` - ## Architecture ### Token Structure @@ -225,33 +237,6 @@ Tokens contain: - **data**: Token-specific data - **transactions**: The history of transactions performed with this token -### Offline Transaction Components - -**OfflineTransaction**: A serializable container for offline token transfers containing: -- **commitment**: The OfflineCommitment with transaction details -- **token**: The complete token being transferred -- **toJSON()**: Serializes the entire package for offline transfer -- **fromJSON()**: Deserializes from JSON with proper factory-based reconstruction - -**OfflineCommitment**: A transaction commitment created without network submission containing: -- **requestId**: Unique request identifier for the transaction -- **transactionData**: The transaction details and state transition -- **authenticator**: Cryptographic signature over the transaction - -### BigInt JSON Serialization - -The SDK handles BigInt values that may cause JSON serialization issues: - -**JsonUtils**: Utility for BigInt-safe JSON operations: -- **stringify()**: JSON.stringify with automatic BigInt to string conversion -- **safeStringify()**: Calls toJSON() first, then applies BigInt-safe stringify -- **parse()**: Standard JSON.parse (use class-specific fromJSON for proper deserialization) - -**Recommended Usage**: -- Use `OfflineTransaction.toJSONString()` for actual transfer/storage -- Use `OfflineTransaction.fromJSONString()` for deserialization -- For manual operations: `JsonUtils.safeStringify(obj)` and `JsonUtils.parse(str)` - ### Privacy Model - **Commitment-based**: Only cryptographic commitments published on-chain - **Self-contained**: Tokens include complete transaction history @@ -323,47 +308,41 @@ npm run lint:fix Note that the examples here are using some utility functions and classes that are defined below in a separate section. ```typescript -// In real usage, this secret must be persisted in order to keep access to your tokens -// This secret belongs to the minter and like all secrets, should be kept private -const initialOwnerSecret = crypto.getRandomValues(new Uint8Array(32)); - -// Connect to the testnet -const client = new StateTransitionClient(new AggregatorClient('https://gateway-test.unicity.network:443')); - -// Using randomized coin IDs and amounts here for testing purposes. -// We mint 2 different coins. -const data = await createMintData( - initialOwnerSecret, - new TokenCoinData([ - [new CoinId(crypto.getRandomValues(new Uint8Array(32))), BigInt(Math.round(Math.random() * 90)) + 10n], - [new CoinId(crypto.getRandomValues(new Uint8Array(32))), BigInt(Math.round(Math.random() * 90)) + 10n], - ]), -); +// Create aggregator client +const aggregatorClient = new AggregatorClient('https://gateway-test.unicity.network:443'); +const client = new StateTransitionClient(aggregatorClient); -const mintCommitment = await client.submitMintTransaction( - await DirectAddress.create(data.predicate.reference), - data.tokenId, - data.tokenType, - data.tokenData, - data.coinData, - data.salt, - await new DataHasher(HashAlgorithm.SHA256).update(data.stateData).digest(), - null -); +const secret = crypto.getRandomValues(new Uint8Array(128)); // User secret key +const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); // Chosen ID +const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); // Token type +const tokenData = new Uint8Array(0); /* Your own token data object with ISerializable attributes */ +const coinData = new TokenCoinData([/* [CoinId, value] elements to have coins in token */]); +const salt = crypto.getRandomValues(new Uint8Array(32)); /* Your random salt bytes */ +const stateData = new Uint8Array()/* Your state data bytes */; -const mintTransaction = await client.createTransaction( - mintCommitment, - await waitInclusionProof(client, mintCommitment), -); +const nonce = crypto.getRandomValues(new Uint8Array(32)); /* Your random nonce bytes */ +const signingService = await SigningService.createFromSecret(secret, nonce); +const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); +const recipient = await DirectAddress.create(predicate.reference); -const token = new Token( - data.tokenId, - data.tokenType, - data.tokenData, - data.coinData, - await TokenState.create(data.predicate, data.stateData), - [mintTransaction], +const commitment = await client.submitMintTransaction( + await MintTransactionData.create( + tokenId, + tokenType, + tokenData, + coinData, + recipient.toString(), + salt, + await new DataHasher(HashAlgorithm.SHA256).update(stateData).digest(), + null, + ), ); + +// Since submit takes time, inclusion proof might not be immediately available +const inclusionProof = await client.getInclusionProof(commitment); +const mintTransaction = await client.createTransaction(commitment, inclusionProof); + +const token = new Token(await TokenState.create(predicate, stateData), mintTransaction, []); ``` ### Token Transfer @@ -373,6 +352,10 @@ This example begins after the previous example. Here we assume that the tokens h Note that the examples here are using some utility functions and classes that are defined below in a separate section. ```typescript +// Assume that token has already been minted or received and is available +let token: Token; +let senderSecret: Uint8Array; // Sender's secret key + // This secret belongs to the receiver that the token is sent to const receiverSecret = crypto.getRandomValues(new Uint8Array(32)); @@ -388,41 +371,49 @@ const recipientPredicate = await MaskedPredicate.create( ); const recipient = await DirectAddress.create(recipientPredicate.reference); +const recipientDataHash = await new DataHasher(HashAlgorithm.SHA256).update(new TextEncoder().encode('my custom data')).digest(); // The sender creates the transfer transaction, using recipientPredicate.reference sent by the receiver const salt = crypto.getRandomValues(new Uint8Array(32)); +const senderSigningService = await SigningService.createFromSecret(senderSecret, token.state.unlockPredicate.nonce); + const transactionData = await TransactionData.create( token.state, - recipient.toJSON(), + recipient.toString(), salt, - await new DataHasher(HashAlgorithm.SHA256).update(new TextEncoder().encode('my custom data')).digest(), - new TextEncoder().encode('my message'), + recipientDataHash, + new TextEncoder().encode('my transaction message'), token.nametagTokens, ); -const commitment = await client.submitTransaction( - transactionData, - await SigningService.createFromSecret(initialOwnerSecret, data.nonce)); +const commitment = await Commitment.create(transactionData, senderSigningService); +const response = await client.submitCommitment(commitment); +if (response.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Failed to submit transaction commitment: ${response.status}`); +} -const transaction = await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); +// Since submit takes time, inclusion proof might not be immediately available +const inclusionProof = await client.getInclusionProof(commitment); +const transaction = await client.createTransaction(commitment, inclusionProof); // The sender serializes the resulting transaction and sends it to the receiver -const sendingTransactionJson = transaction.toJSON() as ITransactionJson; - +const transactionJson = TransactionJsonSerializer.serialize(transaction); // The sender also serializes the token into JSON and sends it to the receiver -const tokenAsJson = token.toJSON(); +const tokenJson = token.toJSON(); -// The receiver imports the token from the given JSON file -const tokenFactory = new TokenFactory(new PredicateFactory()); -const importedToken = await tokenFactory.create(tokenAsJson, TestTokenData.fromJSON); +const predicateFactory = new PredicateFactory(); +const tokenFactory = new TokenFactory(new TokenJsonSerializer(predicateFactory)); +const transactionSerializer = new TransactionJsonSerializer(predicateFactory); + +// The receiver imports the token from the given JSON file +const importedToken = await tokenFactory.create(tokenJson); // Recipient gets transaction from sender -const importedTransaction = await Transaction.fromJSON( +const importedTransaction = await transactionDeserializer.deserialize( importedToken.id, importedToken.type, - sendingTransactionJson, - new PredicateFactory(), -); + transactionJson, +);; // The recipient finishes the transaction with the recipient predicate const updateToken = await client.finishTransaction( @@ -437,46 +428,74 @@ const updateToken = await client.finishTransaction( For scenarios with limited network connectivity, tokens can be transferred using offline transaction packages: ```typescript -import { OfflineStateTransitionClient } from '@unicitylabs/state-transition-sdk'; -import { OfflineTransaction } from '@unicitylabs/state-transition-sdk'; +// Assume that token has already been minted or received and is available +let token: Token; +let senderSecret: Uint8Array; // Sender's secret key + +// This secret belongs to the receiver that the token is sent to +const receiverSecret = crypto.getRandomValues(new Uint8Array(32)); +const recipientTransactionData = new TextEncoder().encode('my custom data'); + +// Recipient prepares the info for the transfer using token ID and type from the sender. +const nonce = crypto.getRandomValues(new Uint8Array(32)); +const receiverSigningService = await SigningService.createFromSecret(receiverSecret, nonce); +const recipientPredicate = await MaskedPredicate.create( + token.id, + token.type, + receiverSigningService, + HashAlgorithm.SHA256, + nonce, +); -// Create offline client -const offlineClient = new OfflineStateTransitionClient(new AggregatorClient('https://gateway-test.unicity.network')); +const recipient = await DirectAddress.create(recipientPredicate.reference); +const recipientDataHash = await new DataHasher(HashAlgorithm.SHA256).update(recipientTransactionData).digest(); -// Sender creates offline commitment (no network required) +// The sender creates the transfer transaction, using recipientPredicate.reference sent by the receiver const salt = crypto.getRandomValues(new Uint8Array(32)); +const senderSigningService = await SigningService.createFromSecret(senderSecret, token.state.unlockPredicate.nonce); + const transactionData = await TransactionData.create( token.state, - recipient.toJSON(), + recipient.toString(), salt, - await new DataHasher(HashAlgorithm.SHA256).update(new TextEncoder().encode('my custom data')).digest(), - new TextEncoder().encode('my message'), + recipientDataHash, + new TextEncoder().encode('my transaction message'), token.nametagTokens, ); -const offlineCommitment = await offlineClient.createOfflineCommitment( - transactionData, - await SigningService.createFromSecret(initialOwnerSecret, data.nonce) -); +const commitment = await Commitment.create(transactionData, senderSigningService); -// Create offline transaction package -const offlineTransaction = new OfflineTransaction(offlineCommitment, token); +// Sender serializes commitment +const commitmentJson = CommitmentJsonSerializer.serialize(commitment); -// Serialize to JSON string for offline transfer (file, USB, QR code, etc.) -// Use toJSONString() for BigInt-safe serialization -const offlineTransactionJsonString = offlineTransaction.toJSONString(); +// Sender serializes token +const tokenJson = token.toJSON(); -// ... Transfer JSON string offline to recipient ... +const predicateFactory = new PredicateFactory(); +const tokenFactory = new TokenFactory(new TokenJsonSerializer(predicateFactory)); +const commitmentSerializer = await new CommitmentJsonSerializer(predicateFactory); -// Recipient deserializes and submits when network is available -const importedOfflineTransaction = await OfflineTransaction.fromJSONString(offlineTransactionJsonString); -const finalTransaction = await offlineClient.submitOfflineTransaction(importedOfflineTransaction.commitment); +const importedToken = await tokenFactory.create(tokenJson); +const importedCommitment = commitmentSerializer.deserialize( + importedToken.id, + importedToken.type, + parsedJson.commitment, +); -// Complete the transfer -const updatedToken = await client.finishTransaction( - importedOfflineTransaction.token, - await TokenState.create(recipientPredicate, new TextEncoder().encode('my custom data')), - finalTransaction, +const response = await client.submitCommitment(importedCommitment); +if (response.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Failed to submit transaction commitment: ${response.status}`); +} + +// Since submit takes time, inclusion proof might not be immediately available +const inclusionProof = await client.getInclusionProof(importedCommitment); +const transaction = await client.createTransaction(importedCommitment, inclusionProof); + +// The recipient finishes the transaction with the recipient predicate +const updateToken = await client.finishTransaction( + importedToken, + await TokenState.create(recipientPredicate, recipientTransactionData), + transaction, ); ``` @@ -493,288 +512,125 @@ const status = await client.getTokenStatus(token); ### The Token Split Operation ```typescript -const client = new StateTransitionClient(new AggregatorClient('https://gateway-test.unicity.network')); - -// First, let's mint a token in the usual way. -const sumTreeHasherFactory = new DataHasherFactory(NodeDataHasher); -const sumTreeHashAlgorithm = HashAlgorithm.SHA256; - -const secret = new TextEncoder().encode('secret'); - -const unicityToken = new CoinId(crypto.getRandomValues(new Uint8Array(32))); -const alphaToken = new CoinId(crypto.getRandomValues(new Uint8Array(32))); - -const coinData = new TokenCoinData([ - [unicityToken, 10n], - [alphaToken, 20n], -]); -const mintTokenData = await createMintData(secret, coinData); -const mintCommitment = await client.submitMintTransaction( - await DirectAddress.create(mintTokenData.predicate.reference), - mintTokenData.tokenId, - mintTokenData.tokenType, - mintTokenData.tokenData, - mintTokenData.coinData, - mintTokenData.salt, - await new DataHasher(HashAlgorithm.SHA256).update(mintTokenData.stateData).digest(), - null, -); - -const mintTransaction = await client.createTransaction( - mintCommitment, - await waitInclusionProof(client, mintCommitment), -); - -const token = new Token( - mintTokenData.tokenId, - mintTokenData.tokenType, - mintTokenData.tokenData, - mintTokenData.coinData, - await TokenState.create(mintTokenData.predicate, mintTokenData.stateData), - [mintTransaction], -); - -// Now let's split that token into 2 tokens. - -const coinsPerNewTokens = [ - new TokenCoinData([ - [unicityToken, 10n], - [alphaToken, 5n], - ]), - new TokenCoinData([[alphaToken, 15n]]), -]; - -const { commitment, recipientPredicate, newTokenIds, allCoinsTree, coinTrees } = - await client.submitBurnTransactionForSplit( - token, - coinsPerNewTokens, - sumTreeHasherFactory, - sumTreeHashAlgorithm, - secret, - mintTokenData.nonce, - await new DataHasher(HashAlgorithm.SHA256).update(new TextEncoder().encode('my custom data')).digest(), - new TextEncoder().encode('my message'), - ); - -const transaction = await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); - -const updatedToken = await client.finishTransaction( - token, - await TokenState.create(recipientPredicate, new TextEncoder().encode('my custom data')), - transaction, -); - -const splitTokenData: IMintData[] = await Promise.all( - coinsPerNewTokens.map( - async (tokenCoinData, index) => - await createMintTokenDataForSplit(newTokenIds[index], secret, mintTokenData.tokenType, tokenCoinData), - ), -); - -const splitTokens = await Promise.all( - splitTokenData.map(async (tokenData) => { - const burnProofs: Map = new Map(); - for (const [coinId] of tokenData.coinData.coins) { - const pathToCoinTree = await allCoinsTree.getProof( - BigintConverter.decode(HexConverter.decode(coinId.toJSON())), - ); - const pathToCoinAmount = await coinTrees - .get(coinId.toJSON())! - .getProof(BigintConverter.decode(HexConverter.decode(tokenData.tokenId.toJSON()))); - burnProofs.set(coinId.toJSON(), [pathToCoinTree, pathToCoinAmount]); - } - - const mintCommitment = await client.submitMintTransaction( - await DirectAddress.create(tokenData.predicate.reference), - tokenData.tokenId, - tokenData.tokenType, - tokenData.tokenData, - tokenData.coinData, - tokenData.salt, - await new DataHasher(HashAlgorithm.SHA256).update(tokenData.stateData).digest(), - new SplitProof(updatedToken, burnProofs), - ); - const mintTransaction = await client.createTransaction( - mintCommitment, - await waitInclusionProof(client, mintCommitment), - ); - return new Token( - tokenData.tokenId, - tokenData.tokenType, - tokenData.tokenData, - tokenData.coinData, - await TokenState.create(tokenData.predicate, tokenData.stateData), - [mintTransaction], - ); - }), -); +// Create aggregator client +const aggregatorClient = new AggregatorClient('https://gateway-test.unicity.network:443'); +const client = new StateTransitionClient(aggregatorClient); -async function createMintTokenDataForSplit( - tokenId: TokenId, - secret: Uint8Array, - tokenType: TokenType, - coinData: TokenCoinData, -): Promise { - const tokenData = new TestTokenData(crypto.getRandomValues(new Uint8Array(32))); - const data = crypto.getRandomValues(new Uint8Array(32)); +const textEncoder = new TextEncoder(); +const coinId = new CoinId(textEncoder.encode('COIN')); - const salt = crypto.getRandomValues(new Uint8Array(32)); - const nonce = crypto.getRandomValues(new Uint8Array(32)); +const secret = crypto.getRandomValues(new Uint8Array(128)); // User secret key +const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); // Chosen ID +const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); // Token type +const tokenData = new Uint8Array(0); /* Your own token data object with ISerializable attributes */ +const coinData = TokenCoinData.create([[coinId, 100n]]); +const salt = crypto.getRandomValues(new Uint8Array(32)); /* Your random salt bytes */ +const stateData = new Uint8Array(0); /* Your state data bytes */ - const signingService = await SigningService.createFromSecret(secret, nonce); - const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); +const nonce = crypto.getRandomValues(new Uint8Array(32)); /* Your random nonce bytes */ +const signingService = await SigningService.createFromSecret(secret, nonce); +const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); +const recipient = await DirectAddress.create(predicate.reference); - return { - coinData, - data, - nonce, - predicate, - salt, - tokenData, +const mintCommitment = await client.submitMintTransaction( + await MintTransactionData.create( tokenId, tokenType, - }; -} -``` + tokenData, + coinData, + recipient.toString(), + salt, + await new DataHasher(HashAlgorithm.SHA256).update(stateData).digest(), + null, + ), +); -### Utility methods and classes +// Since submit takes time, inclusion proof might not be immediately available +const mintInclusionProof = await client.getInclusionProof(mintCommitment); +const mintTransaction = await client.createTransaction(mintCommitment, mintInclusionProof); -The above code examples are using utility methods and classes, included here: +const token = new Token(await TokenState.create(predicate, stateData), mintTransaction, []); -```typescript -async function createMintData(secret: Uint8Array, coinData: TokenCoinData): Promise { +const builder = new TokenSplitBuilder(); +const predicates = new Map(); +const splits: [CoinId, bigint][] = [ + [coinId, 10n], + [coinId, 20n], + [coinId, 70n], +]; +for (const [id, amount] of splits) { const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); - const tokenData = new TestTokenData(crypto.getRandomValues(new Uint8Array(32))); - - const stateData = crypto.getRandomValues(new Uint8Array(32)); - - const salt = crypto.getRandomValues(new Uint8Array(32)); const nonce = crypto.getRandomValues(new Uint8Array(32)); + const signingService = await SigningService.createFromSecret(secret, nonce); + const stateData = new Uint8Array(); // Your state data bytes - const predicate = await MaskedPredicate.create( - tokenId, - tokenType, - await SigningService.createFromSecret(secret, nonce), - HashAlgorithm.SHA256, - nonce, - ); + const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); + predicates.set(tokenId.toBitString().toBigInt(), predicate); - return { - coinData, - stateData, - nonce, - predicate, - salt, - tokenData, + const address = await DirectAddress.create(predicate.reference); + const splitTokenBuilder = builder.createToken( tokenId, tokenType, - }; -} + new Uint8Array(), + address.toString(), + await TokenState.create(predicate, stateData), + new DataHasherFactory(HashAlgorithm.SHA256, DataHasher), + crypto.getRandomValues(new Uint8Array(32)), + ); -interface IMintData { - tokenId: TokenId; - tokenType: TokenType; - tokenData: TestTokenData; - coinData: TokenCoinData; - stateData: Uint8Array; - salt: Uint8Array; - nonce: Uint8Array; - predicate: MaskedPredicate; + splitTokenBuilder.addCoin(id, amount); } -class TestTokenData implements ISerializable { - public constructor(private readonly _data: Uint8Array) { - this._data = new Uint8Array(_data); - } +const splitResult = await builder.build(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); - public get data(): Uint8Array { - return new Uint8Array(this._data); - } - - public static fromJSON(data: unknown): Promise { - if (typeof data !== 'string') { - throw new Error('Invalid test token data'); - } - - return Promise.resolve(new TestTokenData(HexConverter.decode(data))); - } +const burnPredicate = await BurnPredicate.create( + token.id, + token.type, + crypto.getRandomValues(new Uint8Array(32)), + splitResult.rootHash, +); - public toJSON(): string { - return HexConverter.encode(this._data); - } +const burnData = textEncoder.encode('custom burn token data'); - public toCBOR(): Uint8Array { - return this.data; - } +const burnCommitment = await Commitment.create( + await TransactionData.create( + token.state, + (await DirectAddress.create(burnPredicate.reference)).toString(), + crypto.getRandomValues(new Uint8Array(32)), + await new DataHasher(HashAlgorithm.SHA256).update(burnData).digest(), + textEncoder.encode('custom transaction message'), + ), + await SigningService.createFromSecret(secret, token.state.unlockPredicate.nonce), +); - /** Convert instance to readable string */ - public toString(): string { - return dedent` - TestTokenData: ${HexConverter.encode(this.data)}`; - } +const burnCommitmentResponse = await client.submitCommitment(burnCommitment); +if (burnCommitmentResponse.status !== SubmitCommitmentStatus.SUCCESS) { + throw new Error(`Failed to submit burn commitment: ${burnCommitmentResponse.status}`); } -async function waitInclusionProof( - client: StateTransitionClient, - commitment: Commitment>, - signal: AbortSignal = AbortSignal.timeout(10000), - interval: number = 1000, -): Promise { - while (true) { - try { - const inclusionProof = await client.getInclusionProof(commitment); - if ((await inclusionProof.verify(commitment.requestId.toBigInt())) === InclusionProofVerificationStatus.OK) { - return inclusionProof; - } - } catch (err) { - if (!(err instanceof JsonRpcNetworkError && err.status === 404)) { - throw err; - } - } - - try { - await sleep(interval, signal); - } catch (err) { - throw new Error(String(err || 'Sleep was aborted')); - } - } -} +// Since submit takes time, inclusion proof might not be immediately available +const burnInclusionProof = await client.getInclusionProof(burnCommitment); -async function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(signal.reason); - }, - { once: true }, - ); - }); -} +const burnToken = await client.finishTransaction( + token, + await TokenState.create(burnPredicate, burnData), + await client.createTransaction(burnCommitment, burnInclusionProof), +); -async function sendToken( - client: StateTransitionClient, - token: Token>, - signingService: SigningService, - recipient: DirectAddress, -): Promise> { - const salt = crypto.getRandomValues(new Uint8Array(32)); - const transactionData = await TransactionData.create( - token.state, - recipient.toJSON(), - salt, - await new DataHasher(HashAlgorithm.SHA256).update(new TextEncoder().encode('my custom data')).digest(), - new TextEncoder().encode('my message'), - token.nametagTokens, - ); +const splitTokenDataList = await splitResult.getSplitTokenDataList(burnToken); +const splitTokens = await Promise.all( + splitTokenDataList.map(async (data) => { + const commitment = await client.submitMintTransaction(data.transactionData); - const commitment = await client.submitTransaction(transactionData, signingService); - return await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); -} + // Since submit takes time, inclusion proof might not be immediately available + const inclusionProof = await client.getInclusionProof(commitment); + const transaction = await client.createTransaction(commitment, inclusionProof); + + return new Token(data.state, transaction, []); + }), +); ``` ## Unicity Signature Standard diff --git a/src/transaction/split/SplitResult.ts b/src/transaction/split/SplitResult.ts index fbeddabe..9323cbc8 100644 --- a/src/transaction/split/SplitResult.ts +++ b/src/transaction/split/SplitResult.ts @@ -11,9 +11,15 @@ import { SplitMintReason } from '../../token/fungible/SplitMintReason.js'; import { SplitMintReasonProof } from '../../token/fungible/SplitMintReasonProof.js'; import { TokenCoinData } from '../../token/fungible/TokenCoinData.js'; import { Token } from '../../token/Token.js'; +import { TokenState } from '../../token/TokenState.js'; import { MintTransactionData } from '../MintTransactionData.js'; import { Transaction } from '../Transaction.js'; +interface ISplitTokenResult { + readonly transactionData: MintTransactionData; + readonly state: TokenState; +} + export class SplitResult { public constructor( private readonly tokens: SplitToken[], @@ -25,10 +31,14 @@ export class SplitResult { return this._aggregationTree.hash; } - public async toMintTransactionDataList( + public async getSplitTokenDataList( token: Token>>, - ): Promise[]> { + ): Promise { const tokenCoins = new Map(token.coins?.coins.map(([id, value]) => [id.toJSON(), value]) ?? []); + if (this._coinTrees.size !== tokenCoins.size) { + throw new Error(`Invalid token split: Different amount of coins.`); + } + for (const [coinId, tree] of this._coinTrees) { const tokenAmount = tokenCoins.get(coinId); if (tokenAmount !== tree.sum) { @@ -36,7 +46,7 @@ export class SplitResult { } } - const transactions: MintTransactionData[] = []; + const result: ISplitTokenResult[] = []; for (const splitToken of this.tokens) { const coinData: [CoinId, bigint][] = []; const proofs = new Map(); @@ -54,20 +64,23 @@ export class SplitResult { coinData.push([coinId, amount]); } - transactions.push( - await MintTransactionData.create( + result.push({ + state: splitToken.state, + transactionData: await MintTransactionData.create( splitToken.tokenId, splitToken.tokenType, splitToken.data, TokenCoinData.create(coinData), splitToken.recipient, splitToken.salt, - splitToken.dataHash, + splitToken.state.data + ? await splitToken.stateDataHasherFactory.create().update(splitToken.state.data).digest() + : null, new SplitMintReason(token, proofs), ), - ); + }); } - return transactions; + return result; } } diff --git a/src/transaction/split/SplitToken.ts b/src/transaction/split/SplitToken.ts index fb8462a5..5c6f91a4 100644 --- a/src/transaction/split/SplitToken.ts +++ b/src/transaction/split/SplitToken.ts @@ -1,7 +1,9 @@ -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; +import type { IDataHasher } from '@unicitylabs/commons/lib/hash/IDataHasher.js'; import { CoinId } from '../../token/fungible/CoinId.js'; import { TokenId } from '../../token/TokenId.js'; +import { TokenState } from '../../token/TokenState.js'; import { TokenType } from '../../token/TokenType.js'; export class SplitToken { @@ -10,7 +12,8 @@ export class SplitToken { public readonly tokenType: TokenType, private readonly _data: Uint8Array, public readonly recipient: string, - public readonly dataHash: DataHash, + public readonly state: TokenState, + public readonly stateDataHasherFactory: DataHasherFactory, private readonly _salt: Uint8Array, private readonly _coins: Map, ) { diff --git a/src/transaction/split/SplitTokenBuilder.ts b/src/transaction/split/SplitTokenBuilder.ts index 88da3c75..b83ea048 100644 --- a/src/transaction/split/SplitTokenBuilder.ts +++ b/src/transaction/split/SplitTokenBuilder.ts @@ -1,8 +1,10 @@ -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; +import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; +import type { IDataHasher } from '@unicitylabs/commons/lib/hash/IDataHasher.js'; import { SplitToken } from './SplitToken.js'; import { CoinId } from '../../token/fungible/CoinId.js'; import { TokenId } from '../../token/TokenId.js'; +import { TokenState } from '../../token/TokenState.js'; import { TokenType } from '../../token/TokenType.js'; export class SplitTokenBuilder { @@ -13,7 +15,8 @@ export class SplitTokenBuilder { public readonly tokenType: TokenType, private readonly _data: Uint8Array, public readonly recipient: string, - public readonly dataHash: DataHash, + public readonly state: TokenState, + public readonly stateDataHasherFactory: DataHasherFactory, private readonly _salt: Uint8Array, ) { this._data = new Uint8Array(_data); @@ -37,13 +40,14 @@ export class SplitTokenBuilder { return this; } - public toSplitToken(): SplitToken { + public build(): SplitToken { return new SplitToken( this.tokenId, this.tokenType, this._data, this.recipient, - this.dataHash, + this.state, + this.stateDataHasherFactory, this._salt, this._coins, ); diff --git a/src/transaction/split/TokenSplitBuilder.ts b/src/transaction/split/TokenSplitBuilder.ts index 679f9281..d9ed187f 100644 --- a/src/transaction/split/TokenSplitBuilder.ts +++ b/src/transaction/split/TokenSplitBuilder.ts @@ -1,4 +1,3 @@ -import { DataHash } from '@unicitylabs/commons/lib/hash/DataHash.js'; import { DataHasherFactory } from '@unicitylabs/commons/lib/hash/DataHasherFactory.js'; import type { IDataHasher } from '@unicitylabs/commons/lib/hash/IDataHasher.js'; import { MerkleSumTreeRootNode } from '@unicitylabs/commons/lib/smst/MerkleSumTreeRootNode.js'; @@ -11,6 +10,7 @@ import { SplitToken } from './SplitToken.js'; import { SplitTokenBuilder } from './SplitTokenBuilder.js'; import { CoinId } from '../../token/fungible/CoinId.js'; import { TokenId } from '../../token/TokenId.js'; +import { TokenState } from '../../token/TokenState.js'; import { TokenType } from '../../token/TokenType.js'; export class TokenSplitBuilder { @@ -21,7 +21,8 @@ export class TokenSplitBuilder { type: TokenType, data: Uint8Array, recipient: string, - dataHash: DataHash, + state: TokenState, + stateDataHasherFactory: DataHasherFactory, salt: Uint8Array, ): SplitTokenBuilder { const idHex = HexConverter.encode(id.bytes); @@ -29,7 +30,7 @@ export class TokenSplitBuilder { throw new Error('Token already exists in split request'); } - const builder = new SplitTokenBuilder(id, type, data, recipient, dataHash, salt); + const builder = new SplitTokenBuilder(id, type, data, recipient, state, stateDataHasherFactory, salt); this.tokens.set(idHex, builder); return builder; } @@ -38,8 +39,8 @@ export class TokenSplitBuilder { const aggregationTree = new SparseMerkleTreeBuilder(factory); const trees = new Map(); const tokens: SplitToken[] = []; - for (const [, builder] of this.tokens.entries()) { - const token = builder.toSplitToken(); + for (const builder of this.tokens.values()) { + const token = builder.build(); for (const [coinId, amount] of token.coins) { const treesKey = coinId.toJSON(); let tree = trees.get(treesKey); diff --git a/tests/MintTokenUtils.ts b/tests/MintTokenUtils.ts index 38cd5b63..2e4f0adb 100644 --- a/tests/MintTokenUtils.ts +++ b/tests/MintTokenUtils.ts @@ -60,34 +60,6 @@ export async function createMintData(secret: Uint8Array, coinData: TokenCoinData }; } -export async function createMintTokenDataForSplit( - tokenId: TokenId, - secret: Uint8Array, - tokenType: TokenType, - coinData: TokenCoinData, -): Promise { - const tokenData = new TestTokenData(crypto.getRandomValues(new Uint8Array(32))); - - const data = crypto.getRandomValues(new Uint8Array(32)); - - const salt = crypto.getRandomValues(new Uint8Array(32)); - const nonce = crypto.getRandomValues(new Uint8Array(32)); - - const signingService = await SigningService.createFromSecret(secret, nonce); - const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); - - return { - coinData, - data, - nonce, - predicate, - salt, - tokenData, - tokenId, - tokenType, - }; -} - export async function mintToken( client: StateTransitionClient, data: IMintData, diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index 67f70434..c39b3210 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -37,16 +37,6 @@ const predicateFactory = new PredicateJsonFactory(); const tokenFactory = new TokenFactory(new TokenJsonSerializer(predicateFactory)); const transactionDeserializer = new TransactionJsonSerializer(predicateFactory); -async function createMintToken( - client: StateTransitionClient, - data: MintTransactionData, - state: TokenState, -): Promise>>> { - const commitment = await client.submitMintTransaction(data); - const transaction = await client.createTransaction(commitment, await waitInclusionProof(client, commitment)); - return new Token(state, transaction); -} - function performCheckForSplitTokens( actualTokens: Token>>[], expectedCoinDataList: TokenCoinData[], @@ -411,12 +401,14 @@ async function splitToken( const predicate = await MaskedPredicate.create(tokenId, tokenType, signingService, HashAlgorithm.SHA256, nonce); predicates.set(tokenId.toBitString().toBigInt(), predicate); + const address = await DirectAddress.create(predicate.reference); const token = builder.createToken( tokenId, tokenType, new Uint8Array(), - (await DirectAddress.create(predicate.reference)).toString(), - await new NodeDataHasher(HashAlgorithm.SHA256).update(textEncoder.encode(customDataString)).digest(), + address.toString(), + await TokenState.create(predicate, textEncoder.encode(customDataString)), + new DataHasherFactory(HashAlgorithm.SHA256, DataHasher), crypto.getRandomValues(new Uint8Array(32)), ); @@ -425,13 +417,13 @@ async function splitToken( } } - const splitData = await builder.build(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); + const splitResult = await builder.build(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); const burnPredicate = await BurnPredicate.create( token.id, token.type, crypto.getRandomValues(new Uint8Array(32)), - splitData.rootHash, + splitResult.rootHash, ); const commitment = await Commitment.create( @@ -454,13 +446,16 @@ async function splitToken( transaction, ); - const mintTransactions = await splitData.toMintTransactionDataList(updatedToken); + const splitTokenDataList = await splitResult.getSplitTokenDataList(updatedToken); return Promise.all( - mintTransactions.map((data) => - TokenState.create( - predicates.get(data.tokenId.toBitString().toBigInt())!, - textEncoder.encode(customDataString), - ).then((state) => createMintToken(client, data, state)), - ), + splitTokenDataList.map(async (data) => { + const commitment = await client.submitMintTransaction(data.transactionData); + + // Since submit takes time, inclusion proof might not be immediately available + const inclusionProof = await client.getInclusionProof(commitment); + const transaction = await client.createTransaction(commitment, inclusionProof); + + return new Token(data.state, transaction, []); + }), ); } From e76435421d8f22c01d215692c3b2d73ab7fbf3c0 Mon Sep 17 00:00:00 2001 From: Martti Marran Date: Thu, 3 Jul 2025 18:09:04 +0300 Subject: [PATCH 09/10] Updated tests --- src/token/fungible/CoinId.ts | 2 +- tests/token/CommonTestFlow.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/token/fungible/CoinId.ts b/src/token/fungible/CoinId.ts index 457be1db..96d7bc11 100644 --- a/src/token/fungible/CoinId.ts +++ b/src/token/fungible/CoinId.ts @@ -42,6 +42,6 @@ export class CoinId { * Converts the CoinId to a bitstring representation. */ public toBitString(): BitString { - return new BitString(this.toCBOR()); + return new BitString(this.data); } } diff --git a/tests/token/CommonTestFlow.ts b/tests/token/CommonTestFlow.ts index c39b3210..d2ec4340 100644 --- a/tests/token/CommonTestFlow.ts +++ b/tests/token/CommonTestFlow.ts @@ -452,7 +452,7 @@ async function splitToken( const commitment = await client.submitMintTransaction(data.transactionData); // Since submit takes time, inclusion proof might not be immediately available - const inclusionProof = await client.getInclusionProof(commitment); + const inclusionProof = await waitInclusionProof(client, commitment); const transaction = await client.createTransaction(commitment, inclusionProof); return new Token(data.state, transaction, []); From 4f208cef86c9272f3cdd6a2e2cf513e1058beb2b Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 3 Jul 2025 19:34:10 +0400 Subject: [PATCH 10/10] readme fixes --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 10241fd0..997affa8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ const secret = crypto.getRandomValues(new Uint8Array(128)); // User secret key const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); // Chosen ID const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); // Token type const tokenData = new Uint8Array(0); /* Your own token data object with ISerializable attributes */ -const coinData = new TokenCoinData([/* [CoinId, value] elements to have coins in token */]); +const coinData = TokenCoinData.create([/* [CoinId, value] elements to have coins in token */]); const salt = crypto.getRandomValues(new Uint8Array(32)); /* Your random salt bytes */ const stateData = new Uint8Array()/* Your state data bytes */; @@ -173,8 +173,8 @@ const maskedPredicate = await MaskedPredicate.create( ```typescript const textEncoder = new TextEncoder(); -const tokenData = new TokenCoinData([ - { coinId: new CoinId(textEncoder.encode('ALPHA_COIN')), value: BigInt(1000) } +const tokenData = TokenCoinData.create([ + [new CoinId(textEncoder.encode('ALPHA_COIN')), BigInt(1000)] ]); ``` @@ -316,7 +316,7 @@ const secret = crypto.getRandomValues(new Uint8Array(128)); // User secret key const tokenId = TokenId.create(crypto.getRandomValues(new Uint8Array(32))); // Chosen ID const tokenType = TokenType.create(crypto.getRandomValues(new Uint8Array(32))); // Token type const tokenData = new Uint8Array(0); /* Your own token data object with ISerializable attributes */ -const coinData = new TokenCoinData([/* [CoinId, value] elements to have coins in token */]); +const coinData = TokenCoinData.create([/* [CoinId, value] elements to have coins in token */]); const salt = crypto.getRandomValues(new Uint8Array(32)); /* Your random salt bytes */ const stateData = new Uint8Array()/* Your state data bytes */; @@ -502,7 +502,9 @@ const updateToken = await client.finishTransaction( ### Checking Token Status ```typescript -const status = await client.getTokenStatus(token); +// You need the public key of the current owner to check token status +const publicKey = signingService.getPublicKey(); +const status = await client.getTokenStatus(token, publicKey); /* status InclusionProofVerificationStatus.OK is spent status InclusionProofVerificationStatus.PATH_NOT_INCLUDED is unspent