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
5 changes: 5 additions & 0 deletions .changeset/assets-dotted-unit.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 21 additions & 9 deletions packages/evolution/src/Assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<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.
*/
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).
*
Expand All @@ -97,10 +115,7 @@ export const fromAsset = (
*/
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 @@ -161,8 +176,7 @@ export const fromRecord = (record: Record<string, bigint>): 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)
}

Expand Down Expand Up @@ -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

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