From 2ce5890126229ae7cf3520f8a73ef6bf715bc26f Mon Sep 17 00:00:00 2001 From: yanggu0t Date: Sat, 26 Sep 2026 22:41:11 +0800 Subject: [PATCH 1/2] fix: sort equal-length CBOR map keys bytewise --- packages/evolution/src/CBOR.ts | 11 ++++- packages/evolution/test/CBOR.test.ts | 60 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 9cb8f29d..3d9b5734 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -1388,7 +1388,16 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions, encodedKey: internalEncodeSync(key, options), encodedValue: internalEncodeSync(val, options) })) - encodedPairs.sort((a, b) => a.encodedKey.length - b.encodedKey.length) + encodedPairs.sort((a, b) => { + const lengthDifference = a.encodedKey.length - b.encodedKey.length + if (lengthDifference !== 0) return lengthDifference + // Equal-length keys still need bytewise ordering, e.g. token policy IDs. + for (let i = 0; i < a.encodedKey.length; i++) { + const byteDifference = a.encodedKey[i] - b.encodedKey[i] + if (byteDifference !== 0) return byteDifference + } + return 0 + }) } else { encodedPairs = new Array(length) for (let i = 0; i < length; i++) { diff --git a/packages/evolution/test/CBOR.test.ts b/packages/evolution/test/CBOR.test.ts index 9b50bc6c..4c3df3fa 100644 --- a/packages/evolution/test/CBOR.test.ts +++ b/packages/evolution/test/CBOR.test.ts @@ -269,6 +269,66 @@ describe("CBOR Implementation Tests", () => { }) describe("CBOR Deterministic Encoding", () => { + it.each([ + { name: "canonical", options: CBOR.CANONICAL_OPTIONS }, + { name: "custom sorted maps", options: { ...CBOR.CML_DEFAULT_OPTIONS, sortMapKeys: true } } + ])("sorts equal-length encoded keys bytewise in $name mode", ({ options }) => { + const forward = new Map([ + [new Uint8Array([0, 1]), 10n], + [new Uint8Array([0, 2]), 20n] + ]) + const reverse = new Map([...forward].reverse()) + const expected = "a24200010a42000214" + expect(CBOR.toCBORHex(forward, options)).toBe(expected) + expect(CBOR.toCBORHex(reverse, options)).toBe(expected) + }) + + it("sorts equal-length text keys independently of insertion order", () => { + expect( + CBOR.toCBORHex( + new Map([ + ["b", 2n], + ["a", 1n] + ]), + CBOR.CANONICAL_OPTIONS + ) + ).toBe("a2616101616202") + }) + + it("preserves length-first ordering before applying the bytewise tie-break", () => { + const value = new Map([ + [24n, 1n], + [-1n, 2n], + [0n, 3n] + ]) + expect(CBOR.toCBORHex(value, CBOR.CANONICAL_OPTIONS)).toBe("a300032002181801") + }) + + it("sorts nested policy and asset-name maps", () => { + const lowerPolicy = new Uint8Array(28).fill(0x11) + const higherPolicy = new Uint8Array(28).fill(0x22) + const value = new Map([ + [ + higherPolicy, + new Map([ + [new Uint8Array([0xbb]), 2n], + [new Uint8Array([0xaa]), 1n] + ]) + ], + [lowerPolicy, new Map([[new Uint8Array(), 3n]])] + ]) + expect(CBOR.toCBORHex(value, CBOR.CANONICAL_OPTIONS)).toBe( + `a2581c${"11".repeat(28)}a14003581c${"22".repeat(28)}a241aa0141bb02` + ) + }) + + it("preserves default insertion order and explicitly captured CBOR formats", () => { + const hex = "a2616202616101" + const decoded = CBOR.fromCBORHexWithFormat(hex) + expect(CBOR.toCBORHex(decoded.value)).toBe(hex) + expect(CBOR.toCBORHexWithFormat(decoded.value, decoded.format)).toBe(hex) + }) + it("should produce deterministic CBOR encoding", () => { const testData = [42n, "deadbeef", [1n, 2n], new Map([[1n, "cafe"]]), new Uint8Array([0x42, 0xca, 0xfe])] From fb7554e24e4c899953f3daba41a8517a572cc317 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Mon, 28 Sep 2026 14:21:01 -0600 Subject: [PATCH 2/2] release: changeset for canonical map key order --- .changeset/canonical-map-key-order.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/canonical-map-key-order.md diff --git a/.changeset/canonical-map-key-order.md b/.changeset/canonical-map-key-order.md new file mode 100644 index 00000000..45902765 --- /dev/null +++ b/.changeset/canonical-map-key-order.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Sort equal-length map keys bytewise in canonical CBOR encoding. Canonical mode and custom mode with `sortMapKeys: true` ordered map keys by encoded length only, so keys of the same length kept their insertion order. They now follow the length-first rule of RFC 8949 section 4.2.3, shorter keys first and equal lengths in bytewise order, which is the order the ledger and hardware wallets expect. The default encoding still keeps insertion order, and re-encoding with a captured format still reproduces the original bytes.