Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cip21-map-key-order.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/evolution/src/Bytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions packages/evolution/src/Mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array, Map<Uint8Array, bigint>>
// CIP-21 canonical key order; insertion order would make hardware wallets reject the transaction.
const policies: Array<[Uint8Array, Map<Uint8Array, bigint>]> = []

for (const [policyId, assetMap] of toA.map.entries()) {
const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId)
const innerMap = new Map() as Map<Uint8Array, bigint>
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) =>
Expand Down
14 changes: 8 additions & 6 deletions packages/evolution/src/MultiAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array, Map<Uint8Array, bigint>>()
// CIP-21 canonical key order; insertion order would make hardware wallets reject the transaction.
const policies: Array<[Uint8Array, Map<Uint8Array, bigint>]> = []

for (const [policyId, assetMap] of toA.map.entries()) {
const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId)
const innerMap = new Map<Uint8Array, bigint>()
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) =>
Expand Down
9 changes: 6 additions & 3 deletions packages/evolution/src/Withdrawals.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -117,12 +118,14 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Wit
strict: true,
encode: (toA) =>
Eff.gen(function* () {
const withdrawalsMap = new Map<Uint8Array, bigint>()
// 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* () {
Expand Down
82 changes: 82 additions & 0 deletions packages/evolution/test/CIP21.mapKeyOrder.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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)
})
})