diff --git a/.changeset/rare-lions-see.md b/.changeset/rare-lions-see.md new file mode 100644 index 000000000..d77a0b0e0 --- /dev/null +++ b/.changeset/rare-lions-see.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Create `currencies` module in SDK. diff --git a/apps/ensindexer/src/lib/currencies.ts b/apps/ensindexer/src/lib/currencies.ts index 5d59f55b5..7a879344e 100644 --- a/apps/ensindexer/src/lib/currencies.ts +++ b/apps/ensindexer/src/lib/currencies.ts @@ -10,58 +10,7 @@ import { sepolia, } from "viem/chains"; -import type { AccountId, ChainId } from "@ensnode/ensnode-sdk"; - -/** - * Identifiers for supported currencies. - * - * TODO: Add support for WETH - */ -export const CurrencyIds = { - ETH: "ETH", - USDC: "USDC", - DAI: "DAI", -} as const; - -export type CurrencyId = (typeof CurrencyIds)[keyof typeof CurrencyIds]; - -export interface Price { - currency: CurrencyId; - - /** - * The amount of the currency in the smallest unit of the currency. (see - * decimals of the CurrencyConfig for the currency). - * - * Guaranteed to be non-negative. - */ - amount: bigint; -} - -export interface CurrencyInfo { - id: CurrencyId; - name: string; - decimals: number; -} - -const currencyInfo: Record = { - [CurrencyIds.ETH]: { - id: CurrencyIds.ETH, - name: "Ethereum", - decimals: 18, - }, - [CurrencyIds.USDC]: { - id: CurrencyIds.USDC, - name: "USDC", - decimals: 6, - }, - [CurrencyIds.DAI]: { - id: CurrencyIds.DAI, - name: "Dai Stablecoin", - decimals: 18, - }, -}; - -export const getCurrencyInfo = (currencyId: CurrencyId): CurrencyInfo => currencyInfo[currencyId]; +import { type AccountId, type ChainId, type CurrencyId, CurrencyIds } from "@ensnode/ensnode-sdk"; // NOTE: this mapping currently only considers the subset of chains where we have // supported token issuing contracts. diff --git a/apps/ensindexer/src/lib/tokenscope/sales.ts b/apps/ensindexer/src/lib/tokenscope/sales.ts index f64dc8f76..63c463bdc 100644 --- a/apps/ensindexer/src/lib/tokenscope/sales.ts +++ b/apps/ensindexer/src/lib/tokenscope/sales.ts @@ -1,6 +1,7 @@ import type { Address, Hex } from "viem"; -import type { Price } from "@/lib/currencies"; +import type { Price } from "@ensnode/ensnode-sdk"; + import type { SupportedNFT } from "@/lib/tokenscope/assets"; export interface SupportedPayment { diff --git a/apps/ensindexer/src/lib/tokenscope/seaport.ts b/apps/ensindexer/src/lib/tokenscope/seaport.ts index afe648f5e..10db16242 100644 --- a/apps/ensindexer/src/lib/tokenscope/seaport.ts +++ b/apps/ensindexer/src/lib/tokenscope/seaport.ts @@ -1,7 +1,7 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; -import { type ChainId, uniq } from "@ensnode/ensnode-sdk"; +import { type ChainId, CurrencyIds, uniq } from "@ensnode/ensnode-sdk"; -import { CurrencyIds, getCurrencyIdForContract } from "@/lib/currencies"; +import { getCurrencyIdForContract } from "@/lib/currencies"; import { type AssetNamespace, AssetNamespaces } from "@/lib/tokenscope/assets"; import { getSupportedNFTIssuer } from "@/lib/tokenscope/nft-issuers"; import type { SupportedPayment, SupportedSale } from "@/lib/tokenscope/sales"; diff --git a/packages/ensnode-sdk/src/shared/currencies.test.ts b/packages/ensnode-sdk/src/shared/currencies.test.ts new file mode 100644 index 000000000..f06ed9729 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/currencies.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { + addPrices, + CurrencyIds, + type CurrencyInfo, + getCurrencyInfo, + isPriceCurrencyEqual, + isPriceEqual, + type Price, + type PriceDai, + type PriceEth, + type PriceUsdc, + priceDai, + priceEth, + priceUsdc, +} from "./currencies"; + +describe("Currencies", () => { + describe("getCurrencyInfo", () => { + it("returns CurrencyInfo for requested CurrencyId", () => { + expect(getCurrencyInfo(CurrencyIds.ETH)).toStrictEqual({ + id: CurrencyIds.ETH, + name: "ETH", + decimals: 18, + } satisfies CurrencyInfo); + }); + }); + + describe("priceEth", () => { + it("returns correct Price object", () => { + expect(priceEth(1n)).toStrictEqual({ + amount: 1n, + currency: CurrencyIds.ETH, + } satisfies PriceEth); + }); + }); + + describe("priceUsdc", () => { + it("returns correct Price object", () => { + expect(priceUsdc(1n)).toStrictEqual({ + amount: 1n, + currency: CurrencyIds.USDC, + } satisfies PriceUsdc); + }); + }); + + describe("priceDai", () => { + it("returns correct Price object", () => { + expect(priceDai(1n)).toStrictEqual({ + amount: 1n, + currency: CurrencyIds.DAI, + } satisfies PriceDai); + }); + }); + + describe("isPriceCurrencyEqual", () => { + it("returns true when two prices have the same currency", () => { + expect(isPriceCurrencyEqual(priceEth(1n), priceEth(1n))).toBe(true); + }); + + it("returns false when two prices have different currency", () => { + expect(isPriceCurrencyEqual(priceEth(1n), priceUsdc(1n))).toBe(false); + }); + }); + + describe("isPriceEqual", () => { + it("returns true when two prices have the same currency and the same amount", () => { + const price = { + amount: 1n, + currency: CurrencyIds.ETH, + } satisfies Price; + + expect(isPriceEqual(price, price)).toBe(true); + }); + + it("returns false when two prices have different currency", () => { + const priceA = { + amount: 1n, + currency: CurrencyIds.ETH, + } satisfies Price; + + const priceB = { + amount: 1n, + currency: CurrencyIds.USDC, + } satisfies Price; + + expect(isPriceEqual(priceA, priceB)).toBe(false); + }); + + it("returns false when two prices have different amount", () => { + const priceA = { + amount: 1n, + currency: CurrencyIds.ETH, + } satisfies Price; + + const priceB = { + amount: 2n, + currency: CurrencyIds.ETH, + } satisfies Price; + + expect(isPriceEqual(priceA, priceB)).toBe(false); + }); + }); + + describe("addPrices", () => { + it("returns a total of at prices which all have the same currency", () => { + expect(addPrices(priceEth(1n), priceEth(2n), priceEth(3n))).toEqual(priceEth(6n)); + }); + it("throws an error if all prices do not have the same currency", () => { + expect(() => addPrices(priceEth(1n), priceDai(2n), priceEth(3n))).toThrowError( + /All prices must have the same currency to be added together/i, + ); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/currencies.ts b/packages/ensnode-sdk/src/shared/currencies.ts new file mode 100644 index 000000000..7b9c2f068 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/currencies.ts @@ -0,0 +1,153 @@ +/** + * Identifiers for supported currencies. + * + * TODO: Add support for WETH + */ +export const CurrencyIds = { + ETH: "ETH", + USDC: "USDC", + DAI: "DAI", +} as const; + +export type CurrencyId = (typeof CurrencyIds)[keyof typeof CurrencyIds]; + +/** + * The amount of the currency in the smallest unit of the currency + * (see {@link CurrencyInfo.decimals} for the currency). + * + * Guaranteed to be non-negative. + */ +export type CurrencyAmount = bigint; + +export interface PriceEth { + currency: typeof CurrencyIds.ETH; + + amount: CurrencyAmount; +} + +export interface PriceDai { + currency: typeof CurrencyIds.DAI; + + amount: CurrencyAmount; +} + +export interface PriceUsdc { + currency: typeof CurrencyIds.USDC; + + amount: CurrencyAmount; +} + +export type Price = PriceEth | PriceDai | PriceUsdc; + +/** + * Serialized representation of {@link Price}. + */ +export interface SerializedPrice extends Omit { + currency: CurrencyId; + + amount: string; +} + +export interface CurrencyInfo { + id: CurrencyId; + name: string; + decimals: number; +} + +const currencyInfo: Record = { + [CurrencyIds.ETH]: { + id: CurrencyIds.ETH, + name: "ETH", + decimals: 18, + }, + [CurrencyIds.USDC]: { + id: CurrencyIds.USDC, + name: "USDC", + decimals: 6, + }, + [CurrencyIds.DAI]: { + id: CurrencyIds.DAI, + name: "Dai Stablecoin", + decimals: 18, + }, +}; + +/** + * Get currency info for a provided currency. + */ +export function getCurrencyInfo(currencyId: CurrencyId): CurrencyInfo { + return currencyInfo[currencyId]; +} + +/** + * Create price in ETH for given amount. + */ +export function priceEth(amount: Price["amount"]): PriceEth { + return { + amount, + currency: CurrencyIds.ETH, + }; +} + +/** + * Create price in USDC for given amount. + */ +export function priceUsdc(amount: Price["amount"]): PriceUsdc { + return { + amount, + currency: CurrencyIds.USDC, + }; +} + +/** + * Create price in DAI for given amount. + */ +export function priceDai(amount: Price["amount"]): PriceDai { + return { + amount, + currency: CurrencyIds.DAI, + }; +} + +/** + * Check if two prices have the same currency. + */ +export function isPriceCurrencyEqual(priceA: Price, priceB: Price): boolean { + return priceA.currency === priceB.currency; +} + +/** + * Check if two {@link Price} values have the same currency and amount. + */ +export function isPriceEqual(priceA: Price, priceB: Price): boolean { + return isPriceCurrencyEqual(priceA, priceB) && priceA.amount === priceB.amount; +} + +/** + * Add prices + * + * @param prices at least two {@link Price} values to be added together. + * @returns total of all prices. + * @throws if not all prices have the same currency. + */ +export function addPrices(...prices: [Price, Price, ...Price[]]): Price { + const firstPrice = prices[0]; + const allPricesInSameCurrency = prices.every((price) => isPriceCurrencyEqual(firstPrice, price)); + + if (allPricesInSameCurrency === false) { + throw new Error("All prices must have the same currency to be added together."); + } + + const { currency } = firstPrice; + + return prices.reduce( + (acc, price) => ({ + amount: acc.amount + price.amount, + currency, + }), + { + amount: 0n, + currency: firstPrice.currency, + } satisfies Price, + ); +} diff --git a/packages/ensnode-sdk/src/shared/index.ts b/packages/ensnode-sdk/src/shared/index.ts index b122291be..70eae532b 100644 --- a/packages/ensnode-sdk/src/shared/index.ts +++ b/packages/ensnode-sdk/src/shared/index.ts @@ -3,6 +3,7 @@ export * from "./account-id"; export * from "./address"; export * from "./cache"; export * from "./collections"; +export * from "./currencies"; export { deserializeBlockNumber, deserializeBlockRef, diff --git a/packages/ensnode-sdk/src/shared/serialize.ts b/packages/ensnode-sdk/src/shared/serialize.ts index 80faf6ba9..1e21e8e8c 100644 --- a/packages/ensnode-sdk/src/shared/serialize.ts +++ b/packages/ensnode-sdk/src/shared/serialize.ts @@ -1,3 +1,4 @@ +import type { Price, SerializedPrice } from "./currencies"; import type { ChainIdString, DatetimeISO8601, UrlString } from "./serialized-types"; import type { ChainId, Datetime } from "./types"; @@ -21,3 +22,13 @@ export function serializeDatetime(datetime: Datetime): DatetimeISO8601 { export function serializeUrl(url: URL): UrlString { return url.toString(); } + +/** + * Serializes a {@link Price} object. + */ +export function serializePrice(price: Price): SerializedPrice { + return { + currency: price.currency, + amount: price.amount.toString(), + }; +} diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.test.ts b/packages/ensnode-sdk/src/shared/zod-schemas.test.ts index d4e441b6d..6f9b4e20d 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { prettifyError, type ZodSafeParseResult } from "zod/v4"; +import { CurrencyIds, priceDai, priceEth, priceUsdc, type SerializedPrice } from "./currencies"; import { makeBooleanStringSchema, makeChainIdSchema, @@ -9,6 +10,7 @@ import { makeIntegerSchema, makeNonNegativeIntegerSchema, makePositiveIntegerSchema, + makePriceSchema, makeUnixTimestampSchema, makeUrlSchema, } from "./zod-schemas"; @@ -122,6 +124,48 @@ describe("ENSIndexer: Shared", () => { }); }); + it("can parse price objects for each supported currency", () => { + expect( + makePriceSchema().parse({ + amount: "12", + currency: CurrencyIds.ETH, + } satisfies SerializedPrice), + ).toStrictEqual(priceEth(12n)); + + expect( + makePriceSchema().parse({ + amount: "102", + currency: CurrencyIds.USDC, + } satisfies SerializedPrice), + ).toStrictEqual(priceUsdc(102n)); + + expect( + makePriceSchema().parse({ + amount: "123", + currency: CurrencyIds.DAI, + } satisfies SerializedPrice), + ).toStrictEqual(priceDai(123n)); + + expect( + formatParseError( + makePriceSchema().safeParse({ + amount: "-123", + currency: CurrencyIds.ETH, + } satisfies SerializedPrice), + ), + ).toMatch(/Price amount must not be negative/i); + + expect( + formatParseError( + makePriceSchema().safeParse({ + amount: "-123", + // @ts-expect-error + currency: "BTC", + } satisfies SerializedPrice), + ), + ).toMatch(/Price currency must be one of ETH, USDC, DAI/i); + }); + describe("Useful error messages", () => { it("can apply custom value labels", () => { expect(formatParseError(makeChainIdStringSchema().safeParse("notanumber"))).toContain( diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index 3e7a4c154..2e386a49f 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -12,6 +12,7 @@ import z from "zod/v4"; import { ENSNamespaceIds } from "../ens"; import { asLowerCaseAddress } from "./address"; +import { type CurrencyId, CurrencyIds, Price } from "./currencies"; import type { BlockRef, ChainId, @@ -235,3 +236,35 @@ export const makeENSNamespaceIdSchema = (valueLabel: string = "ENSNamespaceId") return `Invalid ${valueLabel}. Supported ENS namespace IDs are: ${Object.keys(ENSNamespaceIds).join(", ")}`; }, }); + +const makePriceAmountSchema = (valueLabel: string = "Amount") => + z.coerce + .bigint({ + error: `${valueLabel} must represent a bigint.`, + }) + .nonnegative({ + error: `${valueLabel} must not be negative.`, + }); + +const makePriceCurrencySchema = (currency: CurrencyId, valueLabel: string = "Price Currency") => + z.strictObject({ + amount: makePriceAmountSchema(`${valueLabel} amount`), + + currency: z.literal(currency, { + error: `${valueLabel} currency must be set to '${currency}'.`, + }), + }); + +/** + * Schema for {@link Price} type. + */ +export const makePriceSchema = (valueLabel: string = "Price") => + z.discriminatedUnion( + "currency", + [ + makePriceCurrencySchema(CurrencyIds.ETH, valueLabel), + makePriceCurrencySchema(CurrencyIds.USDC, valueLabel), + makePriceCurrencySchema(CurrencyIds.DAI, valueLabel), + ], + { error: `${valueLabel} currency must be one of ${Object.values(CurrencyIds).join(", ")}` }, + );