Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/assets-dotted-unit.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 38 additions & 12 deletions packages/evolution/src/Assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<policy>.<name>", 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<Assets, Error> =>
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)
Expand Down Expand Up @@ -159,10 +188,8 @@ export const fromRecord = (record: Record<string, bigint>): 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)
}

Expand Down Expand Up @@ -511,17 +538,16 @@ 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
*/
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

Expand Down
90 changes: 90 additions & 0 deletions packages/evolution/test/AssetsUnitSpelling.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})