Skip to content

Commit 824d819

Browse files
authored
Added ENS currency to ensnode-sdk (#2017)
1 parent 7e77c5c commit 824d819

7 files changed

Lines changed: 243 additions & 5 deletions

File tree

.changeset/eager-otters-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ensnode/ensnode-sdk": minor
3+
---
4+
5+
Add `$ENS Tokens` as a supported currency in `@ensnode/ensnode-sdk`: `CurrencyIds.ENSTokens`, `PriceEnsTokens`/`SerializedPriceEnsTokens` types, and the `priceEnsTokens`, `parseEnsTokens`, `serializePriceEnsTokens`, `deserializePriceEnsTokens` helpers.

packages/ensnode-sdk/src/shared/currencies.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import {
1111
minPrice,
1212
type Price,
1313
type PriceDai,
14+
type PriceEnsTokens,
1415
type PriceEth,
1516
type PriceUsdc,
1617
parseDai,
18+
parseEnsTokens,
1719
parseEth,
1820
parseUsdc,
1921
priceDai,
22+
priceEnsTokens,
2023
priceEth,
2124
priceUsdc,
2225
scalePrice,
@@ -31,6 +34,12 @@ describe("Currencies", () => {
3134
name: "ETH",
3235
decimals: 18,
3336
} satisfies CurrencyInfo);
37+
38+
expect(getCurrencyInfo(CurrencyIds.ENSTokens)).toStrictEqual({
39+
id: CurrencyIds.ENSTokens,
40+
name: "$ENS Tokens",
41+
decimals: 18,
42+
} satisfies CurrencyInfo);
3443
});
3544
});
3645

@@ -61,6 +70,15 @@ describe("Currencies", () => {
6170
});
6271
});
6372

73+
describe("priceEnsTokens", () => {
74+
it("returns correct Price object", () => {
75+
expect(priceEnsTokens(1n)).toStrictEqual({
76+
amount: 1n,
77+
currency: CurrencyIds.ENSTokens,
78+
} satisfies PriceEnsTokens);
79+
});
80+
});
81+
6482
describe("isPriceCurrencyEqual", () => {
6583
it("returns true when two prices have the same currency", () => {
6684
expect(isPriceCurrencyEqual(priceEth(1n), priceEth(1n))).toBe(true);
@@ -199,6 +217,13 @@ describe("Currencies", () => {
199217
expect(result.amount).toBe(500000000000000000n);
200218
});
201219

220+
it("should preserve ENSTokens currency type and scale correctly", () => {
221+
const price: PriceEnsTokens = priceEnsTokens(1000000000000000000n);
222+
const result: PriceEnsTokens = scalePrice(price, 0.5);
223+
expect(result.currency).toBe("ENSTokens");
224+
expect(result.amount).toBe(500000000000000000n);
225+
});
226+
202227
it("should preserve currency when scaling by 1", () => {
203228
const price = priceUsdc(1000000n);
204229
const result = scalePrice(price, 1);
@@ -239,6 +264,12 @@ describe("Currencies", () => {
239264
const result: PriceDai = scalePrice(price, 0.5);
240265
expect(result.currency).toBe("DAI");
241266
});
267+
268+
it("should preserve PriceEnsTokens type", () => {
269+
const price: PriceEnsTokens = priceEnsTokens(1000000000000000000n);
270+
const result: PriceEnsTokens = scalePrice(price, 0.5);
271+
expect(result.currency).toBe("ENSTokens");
272+
});
242273
});
243274

244275
describe("error handling", () => {
@@ -536,4 +567,100 @@ describe("Currencies", () => {
536567
});
537568
});
538569
});
570+
571+
describe("parseEnsTokens", () => {
572+
describe("correct format and decimals", () => {
573+
it("should return PriceEnsTokens type with correct currency", () => {
574+
const result = parseEnsTokens("1");
575+
expect(result).toHaveProperty("currency", CurrencyIds.ENSTokens);
576+
expect(result).toHaveProperty("amount");
577+
expect(result.currency).toBe(CurrencyIds.ENSTokens);
578+
expect(typeof result.amount).toBe("bigint");
579+
});
580+
581+
it("should use 18 decimals from getCurrencyInfo", () => {
582+
const currencyInfo = getCurrencyInfo(CurrencyIds.ENSTokens);
583+
expect(currencyInfo.decimals).toBe(18);
584+
585+
// Test that it uses 18 decimals by parsing a value with 18 decimal places
586+
const result = parseEnsTokens("1.123456789012345678");
587+
expect(result.amount).toBe(1123456789012345678n);
588+
});
589+
590+
it("should parse integer ENSTokens values correctly", () => {
591+
expect(parseEnsTokens("1")).toEqual({
592+
currency: CurrencyIds.ENSTokens,
593+
amount: 1000000000000000000n, // 1 $ENS = 10^18 smallest units
594+
} satisfies PriceEnsTokens);
595+
596+
expect(parseEnsTokens("0")).toEqual({
597+
currency: CurrencyIds.ENSTokens,
598+
amount: 0n,
599+
} satisfies PriceEnsTokens);
600+
601+
expect(parseEnsTokens("123")).toEqual({
602+
currency: CurrencyIds.ENSTokens,
603+
amount: 123000000000000000000n,
604+
} satisfies PriceEnsTokens);
605+
});
606+
607+
it("should parse decimal ENSTokens values correctly", () => {
608+
expect(parseEnsTokens("123.456789012345678")).toEqual({
609+
currency: CurrencyIds.ENSTokens,
610+
amount: 123456789012345678000n,
611+
} satisfies PriceEnsTokens);
612+
613+
expect(parseEnsTokens("0.001")).toEqual({
614+
currency: CurrencyIds.ENSTokens,
615+
amount: 1000000000000000n,
616+
} satisfies PriceEnsTokens);
617+
618+
expect(parseEnsTokens("0.5")).toEqual({
619+
currency: CurrencyIds.ENSTokens,
620+
amount: 500000000000000000n,
621+
} satisfies PriceEnsTokens);
622+
});
623+
624+
it("should handle small ENSTokens values (minimum unit)", () => {
625+
expect(parseEnsTokens("0.000000000000000001")).toEqual({
626+
currency: CurrencyIds.ENSTokens,
627+
amount: 1n, // 1 smallest unit
628+
} satisfies PriceEnsTokens);
629+
});
630+
631+
it("should handle maximum precision (18 decimal places)", () => {
632+
expect(parseEnsTokens("1.123456789012345678")).toEqual({
633+
currency: CurrencyIds.ENSTokens,
634+
amount: 1123456789012345678n,
635+
} satisfies PriceEnsTokens);
636+
});
637+
});
638+
639+
describe("error handling", () => {
640+
it("should throw on invalid format", () => {
641+
expect(() => parseEnsTokens("abc")).toThrow();
642+
expect(() => parseEnsTokens("1.2.3")).toThrow();
643+
});
644+
645+
it("should throw on empty string", () => {
646+
expect(() => parseEnsTokens("")).toThrow("amount must be a non-negative decimal string");
647+
});
648+
649+
it("should throw on whitespace-only string", () => {
650+
expect(() => parseEnsTokens(" ")).toThrow("amount must be a non-negative decimal string");
651+
expect(() => parseEnsTokens("\t")).toThrow("amount must be a non-negative decimal string");
652+
expect(() => parseEnsTokens("\n")).toThrow("amount must be a non-negative decimal string");
653+
});
654+
655+
it("should throw on negative values", () => {
656+
expect(() => parseEnsTokens("-1")).toThrow("amount must be a non-negative decimal string");
657+
expect(() => parseEnsTokens("-0.5")).toThrow(
658+
"amount must be a non-negative decimal string",
659+
);
660+
expect(() => parseEnsTokens("-123.456")).toThrow(
661+
"amount must be a non-negative decimal string",
662+
);
663+
});
664+
});
665+
});
539666
});

packages/ensnode-sdk/src/shared/currencies.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const CurrencyIds = {
1111
ETH: "ETH",
1212
USDC: "USDC",
1313
DAI: "DAI",
14+
ENSTokens: "ENSTokens",
1415
} as const;
1516

1617
export type CurrencyId = (typeof CurrencyIds)[keyof typeof CurrencyIds];
@@ -46,7 +47,13 @@ export interface PriceUsdc {
4647
amount: CurrencyAmount;
4748
}
4849

49-
export type Price = PriceEth | PriceDai | PriceUsdc;
50+
export interface PriceEnsTokens {
51+
currency: typeof CurrencyIds.ENSTokens;
52+
53+
amount: CurrencyAmount;
54+
}
55+
56+
export type Price = PriceEth | PriceDai | PriceUsdc | PriceEnsTokens;
5057

5158
/**
5259
* Serialized representation of {@link PriceEth}.
@@ -69,10 +76,21 @@ export interface SerializedPriceUsdc extends Omit<PriceUsdc, "amount"> {
6976
amount: SerializedCurrencyAmount;
7077
}
7178

79+
/**
80+
* Serialized representation of {@link PriceEnsTokens}.
81+
*/
82+
export interface SerializedPriceEnsTokens extends Omit<PriceEnsTokens, "amount"> {
83+
amount: SerializedCurrencyAmount;
84+
}
85+
7286
/**
7387
* Serialized representation of {@link Price}.
7488
*/
75-
export type SerializedPrice = SerializedPriceEth | SerializedPriceDai | SerializedPriceUsdc;
89+
export type SerializedPrice =
90+
| SerializedPriceEth
91+
| SerializedPriceDai
92+
| SerializedPriceUsdc
93+
| SerializedPriceEnsTokens;
7694

7795
export interface CurrencyInfo {
7896
id: CurrencyId;
@@ -96,6 +114,11 @@ const currencyInfo: Record<CurrencyId, CurrencyInfo> = {
96114
name: "Dai Stablecoin",
97115
decimals: 18,
98116
},
117+
[CurrencyIds.ENSTokens]: {
118+
id: CurrencyIds.ENSTokens,
119+
name: "$ENS Tokens",
120+
decimals: 18,
121+
},
99122
};
100123

101124
/**
@@ -135,6 +158,16 @@ export function priceDai(amount: Price["amount"]): PriceDai {
135158
};
136159
}
137160

161+
/**
162+
* Create price in ENS Tokens for given amount.
163+
*/
164+
export function priceEnsTokens(amount: Price["amount"]): PriceEnsTokens {
165+
return {
166+
amount,
167+
currency: CurrencyIds.ENSTokens,
168+
};
169+
}
170+
138171
/**
139172
* Check if two prices have the same currency.
140173
*/
@@ -382,3 +415,30 @@ export function parseDai(value: string): PriceDai {
382415
const amount = parseUnits(value, currencyInfo.decimals);
383416
return priceDai(amount);
384417
}
418+
419+
/**
420+
* Parses a string representation of ENS Tokens into a {@link PriceEnsTokens} object.
421+
*
422+
* Uses {@link getCurrencyInfo} to get the correct number of decimals (18) for ENS Tokens
423+
* and {@link parseUnits} from viem to convert the decimal string to a bigint.
424+
*
425+
* **Note:** Values with more than 18 decimal places will be truncated/rounded by viem's `parseUnits`.
426+
*
427+
* @param value - The decimal string to parse (e.g., "123.456789012345678" for 123.456789012345678 ENS Tokens)
428+
* @returns A PriceEnsTokens object with the amount in the smallest unit (18 decimals)
429+
*
430+
* @throws {Error} If value is empty, whitespace-only or untrimmed
431+
* @throws {Error} If value represents a negative number
432+
* @throws {Error} If value is not a valid decimal string (e.g., "abc", "1.2.3")
433+
*
434+
* @example
435+
* parseEnsTokens("123.456789012345678") // returns { currency: "ENSTokens", amount: 123456789012345678000n }
436+
* parseEnsTokens("1") // returns { currency: "ENSTokens", amount: 1000000000000000000n }
437+
* parseEnsTokens("0.001") // returns { currency: "ENSTokens", amount: 1000000000000000n }
438+
*/
439+
export function parseEnsTokens(value: string): PriceEnsTokens {
440+
validateAmountToParse(value);
441+
const currencyInfo = getCurrencyInfo(CurrencyIds.ENSTokens);
442+
const amount = parseUnits(value, currencyInfo.decimals);
443+
return priceEnsTokens(amount);
444+
}

packages/ensnode-sdk/src/shared/deserialize.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AccountId, ChainId, ChainIdString, Duration, UrlString } from "enssdk";
22
import z, { prettifyError } from "zod/v4";
33

4-
import type { PriceDai, PriceEth, PriceUsdc } from "./currencies";
4+
import type { PriceDai, PriceEnsTokens, PriceEth, PriceUsdc } from "./currencies";
55
import type { BlockNumber, BlockRef, Datetime } from "./types";
66
import {
77
makeAccountIdStringSchema,
@@ -11,6 +11,7 @@ import {
1111
makeDatetimeSchema,
1212
makeDurationSchema,
1313
makePriceDaiSchema,
14+
makePriceEnsTokensSchema,
1415
makePriceEthSchema,
1516
makePriceUsdcSchema,
1617
makeUnixTimestampSchema,
@@ -140,3 +141,17 @@ export function deserializePriceDai(maybePrice: unknown, valueLabel?: string): P
140141

141142
return parsed.data;
142143
}
144+
145+
export function deserializePriceEnsTokens(
146+
maybePrice: unknown,
147+
valueLabel?: string,
148+
): PriceEnsTokens {
149+
const schema = makePriceEnsTokensSchema(valueLabel);
150+
const parsed = schema.safeParse(maybePrice);
151+
152+
if (parsed.error) {
153+
throw new Error(`Cannot deserialize PriceEnsTokens:\n${prettifyError(parsed.error)}\n`);
154+
}
155+
156+
return parsed.data;
157+
}

packages/ensnode-sdk/src/shared/serialize.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import type { ChainId, ChainIdString, DatetimeISO8601, UrlString } from "enssdk"
33
import type {
44
Price,
55
PriceDai,
6+
PriceEnsTokens,
67
PriceEth,
78
PriceUsdc,
89
SerializedPrice,
910
SerializedPriceDai,
11+
SerializedPriceEnsTokens,
1012
SerializedPriceEth,
1113
SerializedPriceUsdc,
1214
} from "./currencies";
@@ -63,3 +65,10 @@ export function serializePriceUsdc(price: PriceUsdc): SerializedPriceUsdc {
6365
export function serializePriceDai(price: PriceDai): SerializedPriceDai {
6466
return serializePrice(price) as SerializedPriceDai;
6567
}
68+
69+
/**
70+
* Serializes a {@link PriceEnsTokens} object.
71+
*/
72+
export function serializePriceEnsTokens(price: PriceEnsTokens): SerializedPriceEnsTokens {
73+
return serializePrice(price) as SerializedPriceEnsTokens;
74+
}

packages/ensnode-sdk/src/shared/zod-schemas.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { asLiteralLabel, encodeLabelHash, labelhashLiteralLabel } from "enssdk";
22
import { describe, expect, it } from "vitest";
33
import { prettifyError, type ZodSafeParseResult } from "zod/v4";
44

5-
import { CurrencyIds, priceDai, priceEth, priceUsdc, type SerializedPrice } from "./currencies";
5+
import {
6+
CurrencyIds,
7+
priceDai,
8+
priceEnsTokens,
9+
priceEth,
10+
priceUsdc,
11+
type SerializedPrice,
12+
} from "./currencies";
613
import {
714
makeBooleanStringSchema,
815
makeChainIdSchema,
@@ -149,6 +156,13 @@ describe("ENSIndexer: Shared", () => {
149156
} satisfies SerializedPrice),
150157
).toStrictEqual(priceDai(123n));
151158

159+
expect(
160+
makePriceSchema().parse({
161+
amount: "456",
162+
currency: CurrencyIds.ENSTokens,
163+
} satisfies SerializedPrice),
164+
).toStrictEqual(priceEnsTokens(456n));
165+
152166
expect(
153167
formatParseError(
154168
makePriceSchema().safeParse({
@@ -166,7 +180,7 @@ describe("ENSIndexer: Shared", () => {
166180
currency: "BTC",
167181
} satisfies SerializedPrice),
168182
),
169-
).toMatch(/Price currency must be one of ETH, USDC, DAI/i);
183+
).toMatch(/Price currency must be one of ETH, USDC, DAI, ENSTokens/i);
170184
});
171185

172186
describe("NormalizedAddress", () => {

0 commit comments

Comments
 (0)