diff --git a/.changeset/cip21-map-key-order.md b/.changeset/cip21-map-key-order.md new file mode 100644 index 00000000..1e8b2b07 --- /dev/null +++ b/.changeset/cip21-map-key-order.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Encode `Mint`, `MultiAsset` and `Withdrawals` map keys in canonical CBOR order, as CIP-21 requires: policy IDs and reward accounts bytewise, and asset names shorter first, then bytewise. The encoders used insertion order, so a transaction that minted under several policies, or held several policies or asset names in one output, could not be signed by a hardware wallet. The Ledger serializes the body in canonical order itself, so its signature did not match the transaction. Decoded transactions still keep their original key order when they are re-encoded, so their hashes and existing signatures are unchanged. diff --git a/packages/evolution/src/Bytes.ts b/packages/evolution/src/Bytes.ts index 0b2181ca..993d8ec5 100644 --- a/packages/evolution/src/Bytes.ts +++ b/packages/evolution/src/Bytes.ts @@ -8,6 +8,22 @@ export const equals = (a: Uint8Array, b: Uint8Array): boolean => { return true } +/** + * Order byte strings as canonical CBOR map keys (RFC 7049 §3.9): shorter first, + * then bytewise. CIP-21 requires this order for policy IDs, asset names and + * withdrawal reward accounts; hardware wallets reject transactions that use any other order. + * + * @since 2.0.0 + * @category ordering + */ +export const compareCanonical = (a: Uint8Array, b: Uint8Array): number => { + if (a.length !== b.length) return a.length - b.length + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return a[i]! - b[i]! + } + return 0 +} + /** * Creates a curried filter that validates exact byte length (for Uint8Array). * Preserves Context inference from the base schema. diff --git a/packages/evolution/src/Mint.ts b/packages/evolution/src/Mint.ts index 40bfd615..c07c6121 100644 --- a/packages/evolution/src/Mint.ts +++ b/packages/evolution/src/Mint.ts @@ -457,22 +457,24 @@ export const FromCDDL = Schema.transformOrFail(Schema.encodedSchema(CDDLSchema), strict: true, encode: (toA) => Eff.gen(function* () { - // Convert Mint to raw Map data for CBOR encoding - const outerMap = new Map() as Map> + // CIP-21 canonical key order; insertion order would make hardware wallets reject the transaction. + const policies: Array<[Uint8Array, Map]> = [] for (const [policyId, assetMap] of toA.map.entries()) { const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId) - const innerMap = new Map() as Map + const assets: Array<[Uint8Array, bigint]> = [] for (const [assetName, amount] of assetMap.entries()) { const assetNameBytes = yield* ParseResult.encode(AssetName.FromBytes)(assetName) - innerMap.set(assetNameBytes, amount) + assets.push([assetNameBytes, amount]) } - outerMap.set(policyIdBytes, innerMap) + assets.sort(([a], [b]) => Bytes.compareCanonical(a, b)) + policies.push([policyIdBytes, new Map(assets)]) } - return outerMap + policies.sort(([a], [b]) => Bytes.compareCanonical(a, b)) + return new Map(policies) }), decode: (fromA) => diff --git a/packages/evolution/src/MultiAsset.ts b/packages/evolution/src/MultiAsset.ts index 350438de..5f8db45a 100644 --- a/packages/evolution/src/MultiAsset.ts +++ b/packages/evolution/src/MultiAsset.ts @@ -424,22 +424,24 @@ export const FromCDDL = Schema.transformOrFail( strict: true, encode: (toI, _, __, toA) => Eff.gen(function* () { - // Convert MultiAsset to raw Map data for CBOR encoding - const outerMap = new Map>() + // CIP-21 canonical key order; insertion order would make hardware wallets reject the transaction. + const policies: Array<[Uint8Array, Map]> = [] for (const [policyId, assetMap] of toA.map.entries()) { const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId) - const innerMap = new Map() + const assets: Array<[Uint8Array, bigint]> = [] for (const [assetName, amount] of assetMap.entries()) { const assetNameBytes = yield* ParseResult.encode(AssetName.FromBytes)(assetName) - innerMap.set(assetNameBytes, amount) + assets.push([assetNameBytes, amount]) } - outerMap.set(policyIdBytes, innerMap) + assets.sort(([a], [b]) => Bytes.compareCanonical(a, b)) + policies.push([policyIdBytes, new Map(assets)]) } - return outerMap + policies.sort(([a], [b]) => Bytes.compareCanonical(a, b)) + return new Map(policies) }), decode: (fromA) => diff --git a/packages/evolution/src/Withdrawals.ts b/packages/evolution/src/Withdrawals.ts index 8e690d9e..bf2a1410 100644 --- a/packages/evolution/src/Withdrawals.ts +++ b/packages/evolution/src/Withdrawals.ts @@ -1,5 +1,6 @@ import { Effect as Eff, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } from "effect" +import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" import * as Coin from "./Coin.js" import * as RewardAccount from "./RewardAccount.js" @@ -117,12 +118,14 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Wit strict: true, encode: (toA) => Eff.gen(function* () { - const withdrawalsMap = new Map() + // CIP-21 canonical key order; insertion order would make hardware wallets reject the transaction. + const withdrawals: Array<[Uint8Array, bigint]> = [] for (const [rewardAccount, coin] of toA.withdrawals.entries()) { const accountBytes = yield* ParseResult.encode(RewardAccount.FromBytes)(rewardAccount) - withdrawalsMap.set(accountBytes, coin) + withdrawals.push([accountBytes, coin]) } - return withdrawalsMap + withdrawals.sort(([a], [b]) => Bytes.compareCanonical(a, b)) + return new Map(withdrawals) }), decode: (fromA) => Eff.gen(function* () { diff --git a/packages/evolution/test/CIP21.mapKeyOrder.test.ts b/packages/evolution/test/CIP21.mapKeyOrder.test.ts new file mode 100644 index 00000000..3d4c273c --- /dev/null +++ b/packages/evolution/test/CIP21.mapKeyOrder.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" + +import * as AssetName from "../src/AssetName.js" +import * as Bytes from "../src/Bytes.js" +import * as CBOR from "../src/CBOR.js" +import * as Mint from "../src/Mint.js" +import * as MultiAsset from "../src/MultiAsset.js" +import * as PolicyId from "../src/PolicyId.js" +import * as RewardAccount from "../src/RewardAccount.js" +import * as Transaction from "../src/Transaction.js" +import * as Withdrawals from "../src/Withdrawals.js" + +// CIP-21 requires map keys in canonical CBOR order (shorter first, then bytewise). +// Hardware wallets serialize the body themselves in that order, so any other order +// yields a body hash that does not match the transaction. + +// Policies in the insertion order a builder produced for a real preprod deployment. +const POLICY_56 = "56ed5fe5fdd4814a765f8764202e36b2edbbae3d9445bc4e0f47b130" +const POLICY_8C = "8cba3e2854202e9f76c70483427622593d918e7bbcfffd7fee9f2333" +const POLICY_73 = "7379415fc9f3b9dd076e5b3cf17ca12e67cf9e4a3763192b949e9275" +const CANONICAL_POLICIES = [POLICY_56, POLICY_73, POLICY_8C] + +// "0000" sorts after "ff": length decides before byte value. +const ASSET_NAMES = ["ff", "0000", "01"] +const CANONICAL_ASSET_NAMES = ["01", "ff", "0000"] +const ASSETS: Array<[AssetName.AssetName, bigint]> = ASSET_NAMES.map((name, i) => [ + AssetName.fromHex(name), + BigInt(i + 1) +]) + +const mapKeysHex = (cbor: CBOR.CBOR): Array => { + if (!(cbor instanceof Map)) throw new Error("expected a CBOR map") + return [...cbor.keys()].map((key) => { + if (!(key instanceof Uint8Array)) throw new Error("expected byte-string keys") + return Bytes.toHex(key) + }) +} + +describe("CIP-21 canonical map key order on encode", () => { + it("Mint emits policy IDs and asset names in canonical order", () => { + const mint = Mint.fromEntries([POLICY_56, POLICY_8C, POLICY_73].map((p) => [PolicyId.fromHex(p), ASSETS])) + + const encoded = CBOR.fromCBORHex(Mint.toCBORHex(mint)) + + expect(mapKeysHex(encoded)).toEqual(CANONICAL_POLICIES) + if (!(encoded instanceof Map)) throw new Error("expected a CBOR map") + for (const assets of encoded.values()) expect(mapKeysHex(assets)).toEqual(CANONICAL_ASSET_NAMES) + }) + + it("MultiAsset emits policy IDs and asset names in canonical order", () => { + const multiAsset = new MultiAsset.MultiAsset({ + map: new Map([POLICY_56, POLICY_8C, POLICY_73].map((p) => [PolicyId.fromHex(p), new Map(ASSETS)])) + }) + + const encoded = CBOR.fromCBORHex(MultiAsset.toCBORHex(multiAsset)) + + expect(mapKeysHex(encoded)).toEqual(CANONICAL_POLICIES) + if (!(encoded instanceof Map)) throw new Error("expected a CBOR map") + for (const assets of encoded.values()) expect(mapKeysHex(assets)).toEqual(CANONICAL_ASSET_NAMES) + }) + + it("Withdrawals emits reward accounts in canonical order", () => { + const accounts = ["f0" + "00".repeat(28), "e0" + "bb".repeat(28), "e0" + "aa".repeat(28)] + const withdrawals = Withdrawals.fromEntries(accounts.map((a) => [RewardAccount.fromHex(a), 0n])) + + const encoded = CBOR.fromCBORHex(Withdrawals.toCBORHex(withdrawals)) + + expect(mapKeysHex(encoded)).toEqual([accounts[2], accounts[1], accounts[0]]) + }) + + it("decoded transactions keep their original mint order, so their hash and signatures stay valid", () => { + // Body mint map lists 8cba… before 7379… (non-canonical). + const nonCanonicalTxHex = + "84a400d90102818258201111111111111111111111111111111111111111111111111111111111111111000180020009a2581c" + + POLICY_8C + + "a14001581c" + + POLICY_73 + + "a14001a0f5f6" + + expect(Transaction.toCBORHex(Transaction.fromCBORHex(nonCanonicalTxHex))).toBe(nonCanonicalTxHex) + }) +})