diff --git a/.changeset/assets-dotted-unit.md b/.changeset/assets-dotted-unit.md new file mode 100644 index 000000000..bcc38dd9a --- /dev/null +++ b/.changeset/assets-dotted-unit.md @@ -0,0 +1,9 @@ +--- +"@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. + +The same shared parse reads a unit case-insensitively. `Bytes.fromHex` accepts either case, so `fromUnit` built an asset from an uppercase unit that `getByUnit` then answered `0n` for, because it compares against `PolicyId.toHex`, which emits lowercase. `getUnits` still emits the canonical lowercase spelling. + +⚠️ `Assets.fromUnit` accepts one input it rejected before: the concatenated spelling `policyId` followed by `assetName` with no dot, which is what `getUnits` emits. Sharing the parse means a dotless unit longer than 56 characters now splits at 56, where before the whole string was taken as the policy id and failed with a `PolicyId` parse error. diff --git a/packages/evolution/src/Assets.ts b/packages/evolution/src/Assets.ts index 3cec80e39..b35a6fe9a 100644 --- a/packages/evolution/src/Assets.ts +++ b/packages/evolution/src/Assets.ts @@ -90,17 +90,46 @@ export const fromAsset = ( } /** - * Create a single asset from unit string format (policyId.assetName or policyId for empty asset name). + * Split a unit string into its policy-id and asset-name hex halves, lowercased. + * + * ⚠️ **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. + * + * Lowercased for the same reason: hex is case-insensitive and `Bytes.fromHex` accepts either case, + * so `fromUnit` built an asset from an uppercase unit that `getByUnit` then answered `0n` for, + * because it compares against `PolicyId.toHex`, which emits lowercase. + */ +const splitUnit = (unit: string): readonly [string, string] => { + const dot = unit.indexOf(".") + const [policyIdHex, assetNameHex] = + dot === -1 ? [unit.slice(0, 56), unit.slice(56)] : [unit.slice(0, dot), unit.slice(dot + 1)] + return [policyIdHex.toLowerCase(), assetNameHex.toLowerCase()] +} + +/** + * Create a single asset from unit string format. + * + * The unit is either the concatenation `policyId(56) + assetName` that {@link getUnits} emits, or + * `policyId.assetName`, or a bare `policyId` for an empty asset name. Either case of hex is + * accepted, and the halves are read back case-insensitively. + * + * ⚠️ The concatenated spelling is newly accepted here. Sharing one parse with {@link getByUnit} + * means a unit longer than 56 characters with no dot now splits at 56, where before the whole + * string was taken as the policy id and rejected with a `PolicyId` parse error. * * @since 2.0.0 * @category constructors */ 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) @@ -159,10 +188,8 @@ export const fromRecord = (record: Record): Assets => { for (const [key, value] of Object.entries(record)) { if (key === "lovelace") continue - // 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) } @@ -511,7 +538,8 @@ export const hasOnlyLovelace = (assets: Assets): boolean => { /** * Get asset quantity by unit string. - * Unit is either "lovelace" or "policyId.assetName" (hex encoded). + * Unit is either "lovelace", the concatenation "policyId" + "assetName", or "policyId.assetName" + * (hex encoded, either case). * * @since 2.0.0 * @category inspection @@ -519,9 +547,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..68ff900a2 --- /dev/null +++ b/packages/evolution/test/AssetsUnitSpelling.test.ts @@ -0,0 +1,90 @@ +/** + * 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) + }) + + it("accepts the joined spelling in fromUnit, which it did not before", async () => { + // ⚠️ A behaviour change beyond the reader: with one shared parse, a dotless unit longer than 56 + // characters splits at 56. Before, the whole string was taken as the policy id and `fromUnit` + // failed with a `PolicyId` parse error for the very spelling `getUnits` emits. + const assets = await Effect.runPromise(Assets.fromUnit(JOINED, 42n)) + expect(Assets.getByUnit(assets, JOINED)).toBe(42n) + }) + + /** + * ⚠️ Hex is case-insensitive and `Bytes.fromHex` accepts either case, so `fromUnit` BUILT an + * asset from an uppercase unit and `getByUnit` answered `0n` for it -- the same silent zero as + * the dotted spelling, from the same mismatch against lowercase-emitting encoders. + */ + it("reads a unit back whatever case it is spelled in", async () => { + const assets = await Effect.runPromise(Assets.fromUnit(JOINED, 42n)) + expect(Assets.getByUnit(assets, JOINED.toUpperCase())).toBe(42n) + expect(Assets.getByUnit(assets, DOTTED.toUpperCase())).toBe(42n) + + const built = await Effect.runPromise(Assets.fromUnit(JOINED.toUpperCase(), 42n)) + expect(Assets.getByUnit(built, JOINED)).toBe(42n) + expect(Assets.getByUnit(built, JOINED.toUpperCase())).toBe(42n) + }) + + it("keeps emitting the canonical lowercase spelling", async () => { + const built = await Effect.runPromise(Assets.fromUnit(JOINED.toUpperCase(), 7n)) + expect(Assets.getUnits(built)).toContain(JOINED) + }) + + /** + * ⚠️ 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) + }) +})