From ef66c72839f46ab37b09aa108270e0762d74fcfa Mon Sep 17 00:00:00 2001 From: jorbuedo Date: Wed, 21 Oct 2026 13:00:09 +0200 Subject: [PATCH] fix(assets): read a dotted unit in getByUnit, as fromUnit writes one `fromUnit` accepts both `policyId+assetName` and `policyId.assetName`, and has since it was written. `getByUnit` and `fromRecord` sliced at 56 characters unconditionally, so an asset built from a dotted unit read back as `0n` with the same string it was built from. `0n` is why this was worth finding: it reads as "this asset is not here" rather than as a parse failure, so a balance is simply wrong and nothing reports it. One `splitUnit` now serves all three. A dot cannot occur in hex, so the two spellings stay unambiguous. --- .changeset/assets-dotted-unit.md | 5 ++ packages/evolution/src/Assets.ts | 30 ++++++--- .../evolution/test/AssetsUnitSpelling.test.ts | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 .changeset/assets-dotted-unit.md create mode 100644 packages/evolution/test/AssetsUnitSpelling.test.ts diff --git a/.changeset/assets-dotted-unit.md b/.changeset/assets-dotted-unit.md new file mode 100644 index 000000000..84dc724f2 --- /dev/null +++ b/.changeset/assets-dotted-unit.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +`Assets.getByUnit` reads a dotted unit (`policyId.assetName`), which `Assets.fromUnit` has always accepted. The two disagreed: a value built from a dotted unit answered `0n` when read back with that same string, because the reader sliced the dotted text at 56 characters. `0n` reads as "this asset is not here", so the disagreement was silent. `fromUnit`, `fromRecord` and `getByUnit` now share one parse, and a dot cannot occur in hex, so both spellings stay unambiguous. diff --git a/packages/evolution/src/Assets.ts b/packages/evolution/src/Assets.ts index 3cec80e39..2ec6be354 100644 --- a/packages/evolution/src/Assets.ts +++ b/packages/evolution/src/Assets.ts @@ -89,6 +89,24 @@ export const fromAsset = ( return new Assets({ lovelace, multiAsset }) } +/** + * Split a unit string into its policy-id and asset-name hex halves. + * + * ⚠️ **Two spellings reach this module and both have to work.** The canonical one is the + * concatenation `policyId(56) + assetName`, which is what {@link getUnits} emits and what + * `Unit.fromUnit` documents. `fromUnit` has always also accepted `policyId.assetName`, and a dot + * cannot occur in hex, so the form is unambiguous and both are honoured here. + * + * Before this was shared, the two disagreed: `fromUnit(".", 42n)` built the asset + * and `getByUnit` with that SAME string answered `0n`, because it sliced the dotted text at 56. + * Zero is a plausible answer -- it reads as "this asset is not here" -- so the disagreement was + * silent, and silent is how it reaches a balance. + */ +const splitUnit = (unit: string): readonly [string, string] => { + const dot = unit.indexOf(".") + return dot === -1 ? [unit.slice(0, 56), unit.slice(56)] : [unit.slice(0, dot), unit.slice(dot + 1)] +} + /** * Create a single asset from unit string format (policyId.assetName or policyId for empty asset name). * @@ -97,10 +115,7 @@ export const fromAsset = ( */ export const fromUnit = (unit: string, quantity: bigint, lovelace: bigint = 0n): Eff.Effect => Eff.gen(function* () { - // Parse "policyId.assetName" or "policyId" (empty asset name) - const dotIndex = unit.indexOf(".") - const policyIdHex = dotIndex === -1 ? unit : unit.slice(0, dotIndex) - const assetNameHex = dotIndex === -1 ? "" : unit.slice(dotIndex + 1) + const [policyIdHex, assetNameHex] = splitUnit(unit) // Decode policy ID from hex (28 bytes = 56 hex chars) const policyIdBytes = Bytes.fromHex(policyIdHex) @@ -161,8 +176,7 @@ export const fromRecord = (record: Record): Assets => { // First 56 chars are policyId, rest is assetName // Schema validation in addByHex will handle invalid inputs - const policyIdHex = key.slice(0, 56) - const assetNameHex = key.slice(56) + const [policyIdHex, assetNameHex] = splitUnit(key) result = addByHex(result, policyIdHex, assetNameHex, value) } @@ -519,9 +533,7 @@ export const hasOnlyLovelace = (assets: Assets): boolean => { export const getByUnit = (assets: Assets, unit: string): bigint => { if (unit === "lovelace") return assets.lovelace - // Parse unit: first 56 chars are policy ID, rest is asset name - const policyIdHex = unit.slice(0, 56) - const assetNameHex = unit.slice(56) + const [policyIdHex, assetNameHex] = splitUnit(unit) if (!assets.multiAsset) return 0n diff --git a/packages/evolution/test/AssetsUnitSpelling.test.ts b/packages/evolution/test/AssetsUnitSpelling.test.ts new file mode 100644 index 000000000..22d292320 --- /dev/null +++ b/packages/evolution/test/AssetsUnitSpelling.test.ts @@ -0,0 +1,62 @@ +/** + * Both spellings of a unit have to round-trip, because both reach this module. + * + * ⚠️ The canonical spelling is the concatenation `policyId(56) + assetName`, which `getUnits` + * emits. `fromUnit` has always also accepted `policyId.assetName`. Before the parse was shared, + * the two disagreed: a value BUILT from a dotted unit answered `0n` when READ BACK with that same + * string, because the reader sliced the dotted text at 56. Zero reads as "this asset is not + * here", so the disagreement was silent -- and silent is how it reaches a balance. + */ +import { Effect } from "effect" +import { describe, expect, it } from "vitest" + +import * as Assets from "../src/Assets.js" + +const POLICY = "b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a7" +const NAME = "6e7574636f696e" +const DOTTED = `${POLICY}.${NAME}` +const JOINED = `${POLICY}${NAME}` + +describe("unit spelling", () => { + it("reads back a dotted unit with the string it was written with", async () => { + const assets = await Effect.runPromise(Assets.fromUnit(DOTTED, 42n)) + expect(Assets.getByUnit(assets, DOTTED)).toBe(42n) + }) + + it("reads back a joined unit with the string it was written with", async () => { + const assets = await Effect.runPromise(Assets.fromUnit(JOINED, 42n)) + expect(Assets.getByUnit(assets, JOINED)).toBe(42n) + }) + + it("treats the two spellings as the same asset, whichever built it", async () => { + const fromDotted = await Effect.runPromise(Assets.fromUnit(DOTTED, 42n)) + const fromJoined = await Effect.runPromise(Assets.fromUnit(JOINED, 42n)) + for (const assets of [fromDotted, fromJoined]) { + expect(Assets.getByUnit(assets, DOTTED)).toBe(42n) + expect(Assets.getByUnit(assets, JOINED)).toBe(42n) + } + }) + + it("emits the canonical joined spelling from getUnits", async () => { + const assets = await Effect.runPromise(Assets.fromUnit(DOTTED, 7n)) + expect(Assets.getUnits(assets)).toContain(JOINED) + }) + + it("reads a record keyed either way", () => { + expect(Assets.getByUnit(Assets.fromRecord({ [DOTTED]: 5n }), JOINED)).toBe(5n) + expect(Assets.getByUnit(Assets.fromRecord({ [JOINED]: 5n }), DOTTED)).toBe(5n) + }) + + /** + * ⚠️ An EMPTY asset name is a real name, and it is where the two spellings are closest. A bare + * 56-char policy and that policy with a trailing dot must mean the same asset -- the preprod + * wallet this is built for holds one. + */ + it("treats an empty asset name the same either way", async () => { + const bare = await Effect.runPromise(Assets.fromUnit(POLICY, 3n)) + const trailing = await Effect.runPromise(Assets.fromUnit(`${POLICY}.`, 3n)) + expect(Assets.getByUnit(bare, POLICY)).toBe(3n) + expect(Assets.getByUnit(trailing, POLICY)).toBe(3n) + expect(Assets.getByUnit(bare, `${POLICY}.`)).toBe(3n) + }) +})