Skip to content

Commit ed23f8a

Browse files
Merge pull request #95 from IntersectMBO/refactor/assets-module
refactor: modularize Assets structure
2 parents 06a585a + 1cdc35c commit ed23f8a

File tree

7 files changed

+325
-62
lines changed

7 files changed

+325
-62
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { ParseResult, Schema } from "effect"
2+
3+
import * as Bytes from "../Bytes.js"
4+
5+
/**
6+
* CRC8 implementation for label checksum calculation.
7+
* Uses polynomial 0x07 (x^8 + x^2 + x + 1) as per CIP-67 specification.
8+
*
9+
* @since 2.0.0
10+
* @category internal
11+
*/
12+
function crc8(data: Uint8Array): number {
13+
let crc = 0
14+
15+
for (let i = 0; i < data.length; i++) {
16+
crc ^= data[i]
17+
18+
for (let j = 0; j < 8; j++) {
19+
if (crc & 0x80) {
20+
crc = (crc << 1) ^ 0x07
21+
} else {
22+
crc = crc << 1
23+
}
24+
}
25+
}
26+
27+
return crc & 0xff
28+
}
29+
30+
/**
31+
* Generate checksum for a hex-encoded number.
32+
*
33+
* @since 2.0.0
34+
* @category internal
35+
*/
36+
function checksum(num: string): string {
37+
return crc8(Bytes.fromHex(num)).toString(16).padStart(2, "0")
38+
}
39+
40+
/**
41+
* Convert a number to a CIP-67 label format.
42+
* Creates an 8-character hex string with format: 0[4-digit-hex][2-digit-checksum]0
43+
*
44+
* Reference: https://cips.cardano.org/cip/CIP-67
45+
*
46+
* @since 2.0.0
47+
* @category conversions
48+
* @example
49+
* ```typescript
50+
* import * as Label from "@evolution-sdk/core/Label"
51+
*
52+
* const label = Label.toLabel(222)
53+
* // => "000de1400"
54+
* ```
55+
*/
56+
export const toLabel = (num: number): string => {
57+
if (num < 0 || num > 65535) {
58+
throw new Error(`Label ${num} out of range: min label 0 - max label 65535.`)
59+
}
60+
const numHex = num.toString(16).padStart(4, "0")
61+
return "0" + numHex + checksum(numHex) + "0"
62+
}
63+
64+
/**
65+
* Parse a CIP-67 label format back to a number.
66+
* Returns undefined if the label format is invalid or checksum doesn't match.
67+
*
68+
* Reference: https://cips.cardano.org/cip/CIP-67
69+
*
70+
* @since 2.0.0
71+
* @category conversions
72+
* @example
73+
* ```typescript
74+
* import * as Label from "@evolution-sdk/core/Label"
75+
*
76+
* const num = Label.fromLabel("000de1400")
77+
* // => 222
78+
*
79+
* const invalid = Label.fromLabel("00000000")
80+
* // => undefined
81+
* ```
82+
*/
83+
export const fromLabel = (label: string): number | undefined => {
84+
if (label.length !== 8 || !(label[0] === "0" && label[7] === "0")) {
85+
return undefined
86+
}
87+
const numHex = label.slice(1, 5)
88+
const num = parseInt(numHex, 16)
89+
const check = label.slice(5, 7)
90+
return check === checksum(numHex) ? num : undefined
91+
}
92+
93+
/**
94+
* Schema for validating and parsing CIP-67 labels.
95+
* Decodes hex string labels to numbers and encodes numbers to label format.
96+
*
97+
* @since 2.0.0
98+
* @category schemas
99+
* @example
100+
* ```typescript
101+
* import * as Label from "@evolution-sdk/core/Label"
102+
* import { Schema } from "effect"
103+
*
104+
* const decoded = Schema.decodeSync(Label.LabelFromHex)("000de1400")
105+
* // => 222
106+
*
107+
* const encoded = Schema.encodeSync(Label.LabelFromHex)(222)
108+
* // => "000de1400"
109+
* ```
110+
*/
111+
export const LabelFromHex = Schema.transformOrFail(
112+
Schema.String.pipe(
113+
Schema.pattern(/^0[0-9a-fA-F]{6}0$/, {
114+
message: () => "Label must be 8 hex characters starting and ending with 0"
115+
})
116+
),
117+
Schema.Int.pipe(Schema.between(0, 65535)),
118+
{
119+
strict: true,
120+
decode: (label, _, ast) => {
121+
const num = fromLabel(label)
122+
if (num === undefined) {
123+
return ParseResult.fail(
124+
new ParseResult.Type(
125+
ast,
126+
label,
127+
"Invalid label: checksum mismatch"
128+
)
129+
)
130+
}
131+
return ParseResult.succeed(num)
132+
},
133+
encode: (num) => ParseResult.succeed(toLabel(num))
134+
}
135+
).annotations({
136+
identifier: "Label.LabelFromHex",
137+
description: "CIP-67 compliant label with checksum validation"
138+
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { Schema } from "effect"
2+
3+
import * as AssetName from "../AssetName.js"
4+
import * as Bytes from "../Bytes.js"
5+
import * as PolicyId from "../PolicyId.js"
6+
import * as Label from "./Label.js"
7+
8+
/**
9+
* Unit represents the concatenation of PolicyId and AssetName as a single hex string.
10+
* Format: policyId (56 chars) + assetName (0-64 chars)
11+
* Special case: "lovelace" represents ADA
12+
*
13+
* @since 2.0.0
14+
* @category model
15+
*/
16+
export type Unit = string
17+
18+
/**
19+
* Result of parsing a Unit string.
20+
*
21+
* @since 2.0.0
22+
* @category model
23+
*/
24+
export interface UnitDetails {
25+
policyId: PolicyId.PolicyId
26+
assetName: AssetName.AssetName | null
27+
name: AssetName.AssetName | null
28+
label: number | null
29+
}
30+
31+
/**
32+
* Parse a Unit string into its components.
33+
* Extracts policy ID, asset name, and CIP-67 label if present.
34+
*
35+
* @since 2.0.0
36+
* @category conversions
37+
* @example
38+
* ```typescript
39+
* import * as Unit from "@evolution-sdk/core/Assets/Unit"
40+
*
41+
* // NFT with CIP-67 label 222
42+
* const details = Unit.fromUnit("a0b1c2...policyId...000de140...name...")
43+
* // => {
44+
* // policyId: PolicyId,
45+
* // assetName: AssetName,
46+
* // name: AssetName (without label),
47+
* // label: 222
48+
* // }
49+
*
50+
* // Regular token without label
51+
* const token = Unit.fromUnit("a0b1c2...policyId...tokenName")
52+
* // => { policyId, assetName, name, label: null }
53+
* ```
54+
*/
55+
export const fromUnit = (unit: Unit): UnitDetails => {
56+
const policyIdHex = unit.slice(0, 56)
57+
const assetNameHex = unit.slice(56) || null
58+
59+
const policyId = Schema.decodeSync(PolicyId.FromHex)(policyIdHex)
60+
61+
if (!assetNameHex) {
62+
return {
63+
policyId,
64+
assetName: null,
65+
name: null,
66+
label: null
67+
}
68+
}
69+
70+
const assetName = Schema.decodeSync(AssetName.FromHex)(assetNameHex)
71+
72+
// Check for CIP-67 label (first 8 chars of asset name)
73+
if (assetNameHex.length >= 8) {
74+
const potentialLabel = assetNameHex.slice(0, 8)
75+
const labelNum = Label.fromLabel(potentialLabel)
76+
77+
if (labelNum !== undefined) {
78+
// Has valid label, extract name without label
79+
const nameHex = assetNameHex.slice(8)
80+
const name = nameHex ? Schema.decodeSync(AssetName.FromHex)(nameHex) : null
81+
return {
82+
policyId,
83+
assetName,
84+
name,
85+
label: labelNum
86+
}
87+
}
88+
}
89+
90+
// No label found
91+
return {
92+
policyId,
93+
assetName,
94+
name: assetName,
95+
label: null
96+
}
97+
}
98+
99+
/**
100+
* Construct a Unit string from components.
101+
* Combines policy ID, optional CIP-67 label, and asset name.
102+
*
103+
* @since 2.0.0
104+
* @category conversions
105+
* @throws {Error} If asset name exceeds 32 bytes
106+
* @throws {Error} If policy ID is invalid length
107+
* @example
108+
* ```typescript
109+
* import * as Unit from "@evolution-sdk/core/Assets/Unit"
110+
*
111+
* // With CIP-67 label
112+
* const unit = Unit.toUnit(policyId, "name", 222)
113+
* // => "policyId" + "000de140" + "name"
114+
*
115+
* // Without label
116+
* const simple = Unit.toUnit(policyId, "tokenName")
117+
* // => "policyId" + "tokenName"
118+
*
119+
* // Policy only (no asset name)
120+
* const policyOnly = Unit.toUnit(policyId)
121+
* // => "policyId"
122+
* ```
123+
*/
124+
export const toUnit = (
125+
policyId: PolicyId.PolicyId,
126+
name?: AssetName.AssetName | string | null,
127+
label?: number | null
128+
): Unit => {
129+
const policyIdHex = Schema.encodeSync(PolicyId.FromHex)(policyId)
130+
131+
if (policyIdHex.length !== 56) {
132+
throw new Error(`Policy id invalid: ${policyIdHex}`)
133+
}
134+
135+
if (!name && !label) {
136+
return policyIdHex
137+
}
138+
139+
const nameHex = name ? (typeof name === "string" ? name : Bytes.toHex(name.bytes)) : ""
140+
const labelHex = label !== null && label !== undefined ? Label.toLabel(label) : ""
141+
142+
const totalHex = labelHex + nameHex
143+
if (totalHex.length > 64) {
144+
throw new Error("Asset name size exceeds 32 bytes.")
145+
}
146+
147+
return policyIdHex + totalHex
148+
}
149+
150+
/**
151+
* Check if a value is the special "lovelace" unit.
152+
*
153+
* @since 2.0.0
154+
* @category predicates
155+
*/
156+
export const isLovelace = (unit: Unit): boolean => unit === "lovelace"
157+
158+
/**
159+
* Schema for validating Unit strings.
160+
*
161+
* @since 2.0.0
162+
* @category schemas
163+
*/
164+
export const UnitSchema = Schema.String.pipe(
165+
Schema.filter(
166+
(s) => s === "lovelace" || (s.length >= 56 && s.length <= 120 && /^[0-9a-fA-F]+$/.test(s)),
167+
{
168+
message: () => 'Unit must be "lovelace" or hex string (56-120 chars)'
169+
}
170+
)
171+
).annotations({
172+
identifier: "Assets.Unit",
173+
description: "Unit identifier for native assets (policyId + assetName)"
174+
})

packages/evolution/src/core/Assets.ts renamed to packages/evolution/src/core/Assets/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Effect as Eff, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } from "effect"
22

3-
import * as AssetName from "./AssetName.js"
4-
import * as CBOR from "./CBOR.js"
5-
import * as Coin from "./Coin.js"
6-
import * as MultiAsset from "./MultiAsset.js"
7-
import * as PolicyId from "./PolicyId.js"
8-
import * as PositiveCoin from "./PositiveCoin.js"
3+
import * as AssetName from "../AssetName.js"
4+
import * as CBOR from "../CBOR.js"
5+
import * as Coin from "../Coin.js"
6+
import * as MultiAsset from "../MultiAsset.js"
7+
import * as PolicyId from "../PolicyId.js"
8+
import * as PositiveCoin from "../PositiveCoin.js"
99

1010
/**
1111
* Assets representing both ADA and native tokens.

packages/evolution/src/core/TxOut.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Either as E, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } from "effect"
22

33
import * as Address from "./Address.js"
4-
import * as Assets from "./Assets.js"
4+
import * as Assets from "./Assets/index.js"
55
import * as CBOR from "./CBOR.js"
66
import * as DatumOption from "./DatumOption.js"
77
import * as ScriptRef from "./ScriptRef.js"

packages/evolution/src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * as AddressEras from "./AddressEras.js"
33
export * as AddressTag from "./AddressTag.js"
44
export * as Anchor from "./Anchor.js"
55
export * as AssetName from "./AssetName.js"
6+
export * as Assets from "./Assets/index.js"
67
export * as AuxiliaryData from "./AuxiliaryData.js"
78
export * as AuxiliaryDataHash from "./AuxiliaryDataHash.js"
89
export * as BaseAddress from "./BaseAddress.js"

packages/evolution/src/sdk/Assets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Effect as Eff, ParseResult, Schema } from "effect"
22
import * as Equal from "effect/Equal"
33

44
import * as AssetName from "../core/AssetName.js"
5-
import * as CoreAssets from "../core/Assets.js"
5+
import * as CoreAssets from "../core/Assets/index.js"
66
import * as CoreMint from "../core/Mint.js"
77
import * as MultiAsset from "../core/MultiAsset.js"
88
import * as NonZeroInt64 from "../core/NonZeroInt64.js"

0 commit comments

Comments
 (0)