diff --git a/.changeset/replace-bigints-in-sdk.md b/.changeset/replace-bigints-in-sdk.md new file mode 100644 index 0000000000..cd9f0d804b --- /dev/null +++ b/.changeset/replace-bigints-in-sdk.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensnode-sdk": minor +"ensapi": patch +"ensindexer": patch +--- + +Added `replaceBigInts` (sourced from `@ponder/utils`) and `toJson` helpers to `@ensnode/ensnode-sdk`. `toJson` now takes an options object (`{ pretty?: boolean }`) with `pretty` defaulting to `false` — pass `{ pretty: true }` for indented output. Migrated all in-repo call sites and dropped the `@ponder/utils` dependency from `ensapi`. diff --git a/apps/ensadmin/package.json b/apps/ensadmin/package.json index 552f96337c..1d7a2ae681 100644 --- a/apps/ensadmin/package.json +++ b/apps/ensadmin/package.json @@ -32,7 +32,6 @@ "@graphiql/toolkit": "0.11.3", "@icons-pack/react-simple-icons": "^13.7.0", "@namehash/namehash-ui": "workspace:*", - "@ponder/utils": "catalog:", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.3", diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index e7bd86264a..d20b10c196 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -41,7 +41,6 @@ "@opentelemetry/sdk-trace-node": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@ponder/client": "catalog:", - "@ponder/utils": "catalog:", "@pothos/core": "^4.10.0", "@pothos/plugin-dataloader": "^4.4.3", "@pothos/plugin-relay": "^4.6.2", diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index ced33fced9..d10ffc66ac 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -1,10 +1,10 @@ -import { replaceBigInts } from "@ponder/utils"; import type { Duration } from "enssdk"; -import type { - ResolvePrimaryNameResponse, - ResolvePrimaryNamesResponse, - ResolveRecordsResponse, +import { + type ResolvePrimaryNameResponse, + type ResolvePrimaryNamesResponse, + type ResolveRecordsResponse, + replaceBigInts, } from "@ensnode/ensnode-sdk"; import { createApp } from "@/lib/hono-factory"; diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 2cf615d158..aee238b7be 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -1,7 +1,6 @@ import config from "@/config"; import { trace } from "@opentelemetry/api"; -import { replaceBigInts } from "@ponder/utils"; import { type AccountId, asInterpretedName, @@ -23,6 +22,7 @@ import { PluginName, type ResolverRecordsSelection, TraceableENSProtocol, + toJson, } from "@ensnode/ensnode-sdk"; import { isBridgedResolver, @@ -119,7 +119,7 @@ async function _resolveForward( } = options; // `selection` may contain bigints (e.g. `abi: ContentType`); stringify safely for tracing. - const selectionString = JSON.stringify(replaceBigInts(selection, String)); + const selectionString = toJson(selection); // trace for external consumers return withEnsProtocolStep( @@ -164,7 +164,7 @@ async function _resolveForward( // construct the set of resolve() operations indicated by node/selection let operations = makeOperations(node, selection); - span.setAttribute("operations", JSON.stringify(replaceBigInts(operations, String))); + span.setAttribute("operations", toJson(operations)); // if no operations were generated, this was an empty selection; give them what they asked for if (operations.length === 0) return makeRecordsResponse(operations); @@ -354,7 +354,7 @@ async function _resolveForward( // Invariant: all operations must be resolved if (!operations.every(isOperationResolved)) { throw new Error( - `Invariant(forward-resolution): Not all operations were resolved at the end of resolution!\n${JSON.stringify(replaceBigInts(operations, String))}`, + `Invariant(forward-resolution): Not all operations were resolved at the end of resolution!\n${toJson(operations, { pretty: true })}`, ); } diff --git a/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts b/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts index 6e21dce0c1..71f5cb7402 100644 --- a/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts +++ b/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts @@ -3,9 +3,9 @@ import config from "@/config"; import type { LabelHash, LiteralLabel, NormalizedAddress } from "enssdk"; import { getENSRootChainId } from "@ensnode/datasources"; +import { toJson } from "@ensnode/ensnode-sdk"; import type { IndexingEngineContext } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { maybeHealLabelByAddrReverseSubname } from "@/lib/maybe-heal-label-by-addr-reverse-subname"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { @@ -101,6 +101,6 @@ export async function healAddrReverseSubnameLabel( // Invariant: by this point, we should have healed all subnames of addr.reverse throw new Error( - `Invariant(healAddrReverseSubnameLabel): Unable to heal the label for subname of addr.reverse with labelHash '${labelHash}'. Event:\n${toJson(event)}`, + `Invariant(healAddrReverseSubnameLabel): Unable to heal the label for subname of addr.reverse with labelHash '${labelHash}'. Event:\n${toJson(event, { pretty: true })}`, ); } diff --git a/apps/ensindexer/src/lib/json-stringify-with-bigints.ts b/apps/ensindexer/src/lib/json-stringify-with-bigints.ts deleted file mode 100644 index 1cebc88f43..0000000000 --- a/apps/ensindexer/src/lib/json-stringify-with-bigints.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { replaceBigInts } from "ponder"; - -/** - * JSON.stringify with bigints replaced. - */ -export const toJson = (value: unknown, pretty = true) => - JSON.stringify(replaceBigInts(value, String), null, pretty ? 2 : undefined); diff --git a/apps/ensindexer/src/lib/managed-names.ts b/apps/ensindexer/src/lib/managed-names.ts index 1e0fdb87e3..4a45cdcb9f 100644 --- a/apps/ensindexer/src/lib/managed-names.ts +++ b/apps/ensindexer/src/lib/managed-names.ts @@ -14,10 +14,9 @@ import { accountIdEqual, getDatasourceContract, maybeGetDatasourceContract, + toJson, } from "@ensnode/ensnode-sdk"; -import { toJson } from "@/lib/json-stringify-with-bigints"; - /** * Many contracts within the ENSv1 Ecosystem are relative to a parent Name. For example, * the .eth BaseRegistrar (and RegistrarControllers) manage direct subnames of .eth. As such, they @@ -162,7 +161,7 @@ export const getManagedName = (contract: AccountId): { name: InterpretedName; no } throw new Error( - `The following contract ${toJson(contract)} does not have a configured Managed Name. See apps/ensindexer/src/lib/managed-names.ts.`, + `The following contract ${toJson(contract, { pretty: true })} does not have a configured Managed Name. See apps/ensindexer/src/lib/managed-names.ts.`, ); }; diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts index 396774865f..e6168f2525 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts @@ -9,7 +9,12 @@ import { } from "enssdk"; import { isAddressEqual, zeroAddress } from "viem"; -import { interpretAddress, isRegistrationFullyExpired, PluginName } from "@ensnode/ensnode-sdk"; +import { + interpretAddress, + isRegistrationFullyExpired, + PluginName, + toJson, +} from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; @@ -25,7 +30,6 @@ import { ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; @@ -120,7 +124,7 @@ export default function () { // Invariant: If there is an existing Registration, it must be fully expired. if (registration && !isFullyExpired) { throw new Error( - `Invariant(BaseRegistrar:NameRegistered): Existing unexpired registration found in NameRegistered, expected none or expired.\n${toJson(registration)}`, + `Invariant(BaseRegistrar:NameRegistered): Existing unexpired registration found in NameRegistered, expected none or expired.\n${toJson(registration, { pretty: true })}`, ); } @@ -184,6 +188,7 @@ export default function () { node, domainId, }, + { pretty: true }, )}`, ); } @@ -193,6 +198,7 @@ export default function () { throw new Error( `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted for a non-BaseRegistrar registration:\n${toJson( { labelHash, managedNode, node, domainId, registration }, + { pretty: true }, )}`, ); } @@ -202,6 +208,7 @@ export default function () { throw new Error( `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted for a BaseRegistrar registration that has a null expiry:\n${toJson( { labelHash, managedNode, node, domainId, registration }, + { pretty: true }, )}`, ); } @@ -219,6 +226,7 @@ export default function () { registration, timestamp: event.block.timestamp, }, + { pretty: true }, )}`, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts index 2002c49a4f..d7524427dc 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts @@ -21,6 +21,7 @@ import { isRegistrationFullyExpired, isRegistrationInGracePeriod, PluginName, + toJson, } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; @@ -38,7 +39,6 @@ import { ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { logger } from "@/lib/logger"; import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; @@ -128,7 +128,7 @@ export default function () { // Invariant: must have Registration if (!registration) { throw new Error( - `Invariant(NameWrapper:Transfer): Registration expected:\n${toJson(registration)}`, + `Invariant(NameWrapper:Transfer): Registration expected:\n${toJson(registration, { pretty: true })}`, ); } @@ -136,7 +136,7 @@ export default function () { const cannotTransferWhileExpired = registration.fuses && isPccFuseSet(registration.fuses); if (isExpired && cannotTransferWhileExpired) { throw new Error( - `Invariant(NameWrapper:Transfer): Transfer of expired Registration with PARENT_CANNOT_CONTROL set:\n${toJson(registration)} ${JSON.stringify({ isPccFuseSet: isPccFuseSet(registration.fuses ?? 0) })}`, + `Invariant(NameWrapper:Transfer): Transfer of expired Registration with PARENT_CANNOT_CONTROL set:\n${toJson(registration, { pretty: true })} ${JSON.stringify({ isPccFuseSet: isPccFuseSet(registration.fuses ?? 0) })}`, ); } @@ -203,21 +203,21 @@ export default function () { // Invariant: Cannot wrap grace period names if (isRegistrationInGracePeriod(registration, event.block.timestamp)) { throw new Error( - `Invariant(NameWrapper:NameWrapped): Cannot wrap direct-subname-of-registrar-managed-names in GRACE_PERIOD \n${toJson(registration)}`, + `Invariant(NameWrapper:NameWrapped): Cannot wrap direct-subname-of-registrar-managed-names in GRACE_PERIOD \n${toJson(registration, { pretty: true })}`, ); } // Invariant: cannot re-wrap, right? NameWrapped -> NameUnwrapped -> NameWrapped if (registration.wrapped) { throw new Error( - `Invariant(NameWrapper:NameWrapped): Re-wrapping already wrapped BaseRegistrar registration\n${toJson(registration)}`, + `Invariant(NameWrapper:NameWrapped): Re-wrapping already wrapped BaseRegistrar registration\n${toJson(registration, { pretty: true })}`, ); } // Invariant: BaseRegistrar always provides expiry if (expiry === null) { throw new Error( - `Invariant(NameWrapper:NameWrapped): Wrap of BaseRegistrar Registration does not include expiry!\n${toJson(registration)}`, + `Invariant(NameWrapper:NameWrapped): Wrap of BaseRegistrar Registration does not include expiry!\n${toJson(registration, { pretty: true })}`, ); } @@ -240,7 +240,7 @@ export default function () { // Invariant: If there's an existing Registration, it should be expired if (registration && !isFullyExpired) { throw new Error( - `Invariant(NameWrapper:NameWrapped): NameWrapped but there's an existing unexpired non-BaseRegistrar Registration:\n${toJson({ registration, timestamp: event.block.timestamp })}`, + `Invariant(NameWrapper:NameWrapped): NameWrapped but there's an existing unexpired non-BaseRegistrar Registration:\n${toJson({ registration, timestamp: event.block.timestamp }, { pretty: true })}`, ); } @@ -327,7 +327,7 @@ export default function () { // Invariant: must have a Registration if (!registration) { throw new Error( - `Invariant(NameWrapper:FusesSet): Registration expected:\n${toJson(registration)}`, + `Invariant(NameWrapper:FusesSet): Registration expected:\n${toJson(registration, { pretty: true })}`, ); } @@ -363,7 +363,7 @@ export default function () { // Invariant: must have Registration if (!registration) { throw new Error( - `Invariant(NameWrapper:ExpiryExtended): Registration expected\n${toJson(registration)}`, + `Invariant(NameWrapper:ExpiryExtended): Registration expected\n${toJson(registration, { pretty: true })}`, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index a94df08e78..fb8d09552b 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -9,7 +9,7 @@ import { makeSubdomainNode, } from "enssdk"; -import { type EncodedReferrer, PluginName } from "@ensnode/ensnode-sdk"; +import { type EncodedReferrer, PluginName, toJson } from "@ensnode/ensnode-sdk"; import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers"; @@ -20,7 +20,6 @@ import { ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; @@ -120,13 +119,16 @@ export default function () { if (!registration) { throw new Error( - `Invariant(RegistrarController:NameRenewed): NameRenewed but no Registration.\n${toJson({ - label, - labelHash, - managedNode, - node, - domainId, - })}`, + `Invariant(RegistrarController:NameRenewed): NameRenewed but no Registration.\n${toJson( + { + label, + labelHash, + managedNode, + node, + domainId, + }, + { pretty: true }, + )}`, ); } @@ -142,6 +144,7 @@ export default function () { domainId, registration, }, + { pretty: true }, )}`, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index a2d20d6fb0..76aafac3ec 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -12,7 +12,12 @@ import { } from "enssdk"; import { hexToBigInt } from "viem"; -import { interpretAddress, isRegistrationFullyExpired, PluginName } from "@ensnode/ensnode-sdk"; +import { + interpretAddress, + isRegistrationFullyExpired, + PluginName, + toJson, +} from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; @@ -27,7 +32,6 @@ import { ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; @@ -65,14 +69,14 @@ export default function () { // Sanity Check: LabelHash must match Label if (labelHash !== labelhashLiteralLabel(label)) { throw new Error( - `Sanity Check: labelHash !== labelhashLiteralLabel(label)\n${toJson(event.args)}`, + `Sanity Check: labelHash !== labelhashLiteralLabel(label)\n${toJson(event.args, { pretty: true })}`, ); } // Sanity Check: StorageId derived from tokenId must match StorageId derived from LabelHash if (storageId !== makeStorageId(hexToBigInt(labelHash))) { throw new Error( - `Sanity Check: storageId !== makeStorageId(hexToBigInt(labelHash))\n${toJson(event.args)}`, + `Sanity Check: storageId !== makeStorageId(hexToBigInt(labelHash))\n${toJson(event.args, { pretty: true })}`, ); } @@ -91,7 +95,7 @@ export default function () { // Invariant: if this is a Reservation, any existing Registration should be fully expired if (registration && !isRegistrationFullyExpired(registration, event.block.timestamp)) { throw new Error( - `Invariant(ENSv2Registry:Label[Registered|Reserved]): Existing unexpired Registration found, expected none or expired.\n${toJson(registration)}`, + `Invariant(ENSv2Registry:Label[Registered|Reserved]): Existing unexpired Registration found, expected none or expired.\n${toJson(registration, { pretty: true })}`, ); } } else { @@ -102,7 +106,7 @@ export default function () { !isRegistrationFullyExpired(registration, event.block.timestamp) ) { throw new Error( - `Invariant(ENSv2Registry:Label[Registered|Reserved]): Existing unexpired Registration found, expected none or expired.\n${toJson(registration)}`, + `Invariant(ENSv2Registry:Label[Registered|Reserved]): Existing unexpired Registration found, expected none or expired.\n${toJson(registration, { pretty: true })}`, ); } } @@ -177,7 +181,7 @@ export default function () { // Invariant: The existing Registration must not be expired. if (isRegistrationFullyExpired(registration, event.block.timestamp)) { throw new Error( - `Invariant(ENSv2Registry:LabelUnregistered): Expected unexpired registration but got:\n${toJson(registration)}`, + `Invariant(ENSv2Registry:LabelUnregistered): Expected unexpired registration but got:\n${toJson(registration, { pretty: true })}`, ); } @@ -227,7 +231,7 @@ export default function () { // Invariant: The existing Registration must not be expired. if (isRegistrationFullyExpired(registration, event.block.timestamp)) { throw new Error( - `Invariant(ENSv2Registry:ExpiryUpdated): Expected unexpired Registration but got:\n${toJson(registration)}`, + `Invariant(ENSv2Registry:ExpiryUpdated): Expected unexpired Registration but got:\n${toJson(registration, { pretty: true })}`, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts index d484bdf90d..8481d90f89 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts @@ -14,6 +14,7 @@ import { interpretAddress, isRegistrationFullyExpired, PluginName, + toJson, } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; @@ -25,7 +26,6 @@ import { ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; -import { toJson } from "@/lib/json-stringify-with-bigints"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs, LogEventBase } from "@/lib/ponder-helpers"; @@ -90,7 +90,7 @@ export default function () { // Invariant: must be ENSv2Registry Registration if (registration.type !== "ENSv2RegistryRegistration") { throw new Error( - `Invariant(ETHRegistrar:NameRegistered): Registration found but not ENSv2Registry Registration:\n${toJson(registration)}`, + `Invariant(ETHRegistrar:NameRegistered): Registration found but not ENSv2Registry Registration:\n${toJson(registration, { pretty: true })}`, ); } @@ -98,7 +98,7 @@ export default function () { const isFullyExpired = isRegistrationFullyExpired(registration, event.block.timestamp); if (isFullyExpired) { throw new Error( - `Invariant(ETHRegistrar:NameRegistered): Registration found but expired:\n${toJson(registration)}`, + `Invariant(ETHRegistrar:NameRegistered): Registration found but expired:\n${toJson(registration, { pretty: true })}`, ); } @@ -163,7 +163,7 @@ export default function () { // Invariant: Must be ENSv2Registry Registration if (registration.type !== "ENSv2RegistryRegistration") { throw new Error( - `Invariant(ETHRegistrar:NameRenewed): Registration found but not ENSv2Registry Registration:\n${toJson(registration)}`, + `Invariant(ETHRegistrar:NameRenewed): Registration found but not ENSv2Registry Registration:\n${toJson(registration, { pretty: true })}`, ); } diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 2e27b8e27a..9676b3c7a6 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ENSApi APIs", - "version": "1.10.0", + "version": "1.10.1", "description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode" }, "servers": [ diff --git a/packages/datasources/package.json b/packages/datasources/package.json index 00c3b8dcc9..9eb98c5545 100644 --- a/packages/datasources/package.json +++ b/packages/datasources/package.json @@ -49,6 +49,6 @@ "vitest": "catalog:" }, "dependencies": { - "@ponder/utils": "catalog:" + "@ponder/utils": "^0.2.18" } } diff --git a/packages/ensnode-sdk/src/index.ts b/packages/ensnode-sdk/src/index.ts index 06ca0096f8..0f01d81786 100644 --- a/packages/ensnode-sdk/src/index.ts +++ b/packages/ensnode-sdk/src/index.ts @@ -28,8 +28,10 @@ export * from "./shared/namespace-specific-value"; export * from "./shared/null-bytes"; export * from "./shared/numbers"; export * from "./shared/prerequisites"; +export * from "./shared/replace-bigints"; export * from "./shared/root-registry"; export * from "./shared/serialize"; +export * from "./shared/to-json"; export * from "./shared/types"; export * from "./shared/url"; export * from "./stack-info"; diff --git a/packages/ensnode-sdk/src/shared/replace-bigints.test.ts b/packages/ensnode-sdk/src/shared/replace-bigints.test.ts new file mode 100644 index 0000000000..0b0c2a7bcb --- /dev/null +++ b/packages/ensnode-sdk/src/shared/replace-bigints.test.ts @@ -0,0 +1,93 @@ +import { type Hex, numberToHex } from "viem"; +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { replaceBigInts } from "./replace-bigints"; + +describe("replaceBigInts", () => { + it("replaces a scalar bigint", () => { + const out = replaceBigInts(5n, numberToHex); + + expect(out).toBe("0x5"); + expectTypeOf(out).toEqualTypeOf(); + }); + + it("passes through non-bigint scalars", () => { + expect(replaceBigInts("hello", String)).toBe("hello"); + expect(replaceBigInts(42, String)).toBe(42); + expect(replaceBigInts(true, String)).toBe(true); + expect(replaceBigInts(false, String)).toBe(false); + }); + + it("passes through null and undefined", () => { + expect(replaceBigInts(null, String)).toBe(null); + expect(replaceBigInts(undefined, String)).toBe(undefined); + }); + + it("handles empty array", () => { + expect(replaceBigInts([], String)).toStrictEqual([]); + }); + + it("handles empty object", () => { + expect(replaceBigInts({}, String)).toStrictEqual({}); + }); + + it("replaces bigints in an array", () => { + const out = replaceBigInts([5n], numberToHex); + + expect(out).toStrictEqual(["0x5"]); + expectTypeOf(out).toEqualTypeOf<[Hex]>(); + }); + + it("replaces bigints in a readonly (as const) array", () => { + const out = replaceBigInts([5n] as const, numberToHex); + + expect(out).toStrictEqual(["0x5"]); + expectTypeOf(out).toEqualTypeOf<[Hex]>(); + }); + + it("replaces bigints in a mixed-type array", () => { + const out = replaceBigInts([1n, "x", 2, null, 3n], String); + + expect(out).toStrictEqual(["1", "x", 2, null, "3"]); + }); + + it("replaces bigints in nested objects", () => { + const out = replaceBigInts({ kevin: { kevin: 5n } }, numberToHex); + + expect(out).toStrictEqual({ kevin: { kevin: "0x5" } }); + expectTypeOf(out).toEqualTypeOf<{ kevin: { kevin: Hex } }>(); + }); + + it("replaces bigints in an object whose value is a bigint array", () => { + const out = replaceBigInts({ values: [1n, 2n, 3n] }, String); + + expect(out).toStrictEqual({ values: ["1", "2", "3"] }); + }); + + it("replaces bigints in deeply nested array-in-object-in-array structures", () => { + const out = replaceBigInts([{ xs: [1n, { y: 2n }] }, { xs: [3n] }], String); + + expect(out).toStrictEqual([{ xs: ["1", { y: "2" }] }, { xs: ["3"] }]); + expectTypeOf(out).toEqualTypeOf<[{ xs: [string, { y: string }] }, { xs: [string] }]>(); + }); + + it("maps general (non-tuple) arrays element-wise", () => { + const input: bigint[] = [1n, 2n]; + const out = replaceBigInts(input, String); + + expect(out).toStrictEqual(["1", "2"]); + expectTypeOf(out).toEqualTypeOf(); + }); + + it("supports an identity replacer", () => { + const out = replaceBigInts(5n, (x) => x); + + expect(out).toBe(5n); + }); + + it("supports the String replacer (common toJson case)", () => { + const out = replaceBigInts({ a: 1n, b: [2n, "x"] }, String); + + expect(out).toStrictEqual({ a: "1", b: ["2", "x"] }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/replace-bigints.ts b/packages/ensnode-sdk/src/shared/replace-bigints.ts new file mode 100644 index 0000000000..739af2ed9b --- /dev/null +++ b/packages/ensnode-sdk/src/shared/replace-bigints.ts @@ -0,0 +1,39 @@ +// Sourced from @ponder/utils to avoid the dependency: +// https://github.com/ponder-sh/ponder/blob/c8f6935fb65176c01b40cae9056be704c0e5318e/packages/utils/src/replaceBigInts.ts + +type _ReplaceBigInts = number extends arr["length"] + ? ReplaceBigInts[] + : arr extends readonly [infer first, ...infer rest] + ? [ReplaceBigInts, ..._ReplaceBigInts] + : []; + +/** + * Designed for plain JSON-like values (records, arrays, primitives, bigint). + * Non-plain objects (e.g. `Date`, `Map`, class instances) are walked via + * `Object.entries` at runtime, which strips prototype methods — pass them + * through unchanged or use `JSON.stringify`'s replacer instead (see `toJson`). + * + * Output arrays and objects are mutable to match runtime behavior + * (`Array.prototype.map` and `Object.fromEntries` both produce mutable values). + */ +export type ReplaceBigInts = obj extends bigint + ? type + : obj extends readonly unknown[] + ? _ReplaceBigInts + : obj extends object + ? { -readonly [key in keyof obj]: ReplaceBigInts } + : obj; + +export const replaceBigInts = ( + obj: T, + replacer: (x: bigint) => type, +): ReplaceBigInts => { + if (typeof obj === "bigint") return replacer(obj) as ReplaceBigInts; + if (Array.isArray(obj)) + return obj.map((x) => replaceBigInts(x, replacer)) as ReplaceBigInts; + if (obj && typeof obj === "object") + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, replaceBigInts(v, replacer)]), + ) as ReplaceBigInts; + return obj as ReplaceBigInts; +}; diff --git a/packages/ensnode-sdk/src/shared/to-json.test.ts b/packages/ensnode-sdk/src/shared/to-json.test.ts new file mode 100644 index 0000000000..f8ee1eecd4 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/to-json.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { toJson } from "./to-json"; + +describe("toJson", () => { + it("serializes bigints as strings", () => { + expect(toJson({ n: 5n })).toBe('{"n":"5"}'); + }); + + it("defaults to compact output", () => { + expect(toJson({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + }); + + it("produces indented output when pretty is true", () => { + expect(toJson({ a: 1 }, { pretty: true })).toBe('{\n "a": 1\n}'); + }); + + it("produces compact output when pretty is false", () => { + expect(toJson({ a: 1 }, { pretty: false })).toBe('{"a":1}'); + }); + + it("handles nested structures with bigints", () => { + expect(toJson({ outer: { inner: 42n, xs: [1n, 2n] } })).toBe( + '{"outer":{"inner":"42","xs":["1","2"]}}', + ); + }); + + it("pretty-prints nested structures with bigints", () => { + expect(toJson({ outer: { n: 1n } }, { pretty: true })).toBe( + '{\n "outer": {\n "n": "1"\n }\n}', + ); + }); + + it("serializes primitives unchanged", () => { + expect(toJson("hello")).toBe('"hello"'); + expect(toJson(42)).toBe("42"); + expect(toJson(true)).toBe("true"); + expect(toJson(null)).toBe("null"); + }); + + it("preserves native toJSON behavior (Date)", () => { + expect(toJson({ at: new Date("2020-01-01T00:00:00.000Z") })).toBe( + '{"at":"2020-01-01T00:00:00.000Z"}', + ); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/to-json.ts b/packages/ensnode-sdk/src/shared/to-json.ts new file mode 100644 index 0000000000..df5ac37d42 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/to-json.ts @@ -0,0 +1,15 @@ +/** + * `JSON.stringify` with bigints replaced by their string representation. + * + * Defaults to compact output. Pass `{ pretty: true }` for 2-space indent + * (useful for human-readable error messages and console logs). + * + * Uses `JSON.stringify`'s replacer callback so native `toJSON` behavior is + * preserved (e.g. `Date` serializes to its ISO string). + */ +export const toJson = (value: unknown, options?: { pretty?: boolean }) => + JSON.stringify( + value, + (_key, val) => (typeof val === "bigint" ? String(val) : val), + options?.pretty ? 2 : undefined, + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b1eaa0da0..b6ed891c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,6 @@ catalogs: '@ponder/client': specifier: 0.16.6 version: 0.16.6 - '@ponder/utils': - specifier: 0.2.18 - version: 0.2.18 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0 @@ -121,13 +118,13 @@ overrides: yauzl@<3.2.1: ^3.2.1 fast-xml-parser@>=5.0.0 <5.5.7: '>=5.5.7' kysely@>=0.26.0 <0.28.14: '>=0.28.14' - defu@<=6.1.4: ^6.1.5 h3@<1.15.9: '>=1.15.9' yaml@>=2.0.0 <2.8.3: '>=2.8.3' picomatch@<2.3.2: ^2.3.2 picomatch@>=4.0.0 <4.0.4: '>=4.0.4' smol-toml@<1.6.1: '>=1.6.1' brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5' + defu@<=6.1.4: ^6.1.5 vite@>=5.0.0 <=6.4.1: ^6.4.2 axios@<1.15.0: '>=1.15.0' follow-redirects@<1.16.0: ^1.16.0 @@ -203,9 +200,6 @@ importers: '@namehash/namehash-ui': specifier: workspace:* version: link:../../packages/namehash-ui - '@ponder/utils': - specifier: 'catalog:' - version: 0.2.18(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6)) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -396,9 +390,6 @@ importers: '@ponder/client': specifier: 'catalog:' version: 0.16.6(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.14)(pg@8.16.3)(typescript@5.9.3) - '@ponder/utils': - specifier: 'catalog:' - version: 0.2.18(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6)) '@pothos/core': specifier: ^4.10.0 version: 4.10.0(graphql@16.11.0) @@ -851,7 +842,7 @@ importers: packages/datasources: dependencies: '@ponder/utils': - specifier: 'catalog:' + specifier: ^0.2.18 version: 0.2.18(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6)) devDependencies: '@ensnode/shared-configs': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b985f549a6..9ce3b1db20 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,6 @@ catalog: "@astrojs/react": ^4.4.2 "@astrojs/tailwind": ^6.0.2 "@ponder/client": 0.16.6 - "@ponder/utils": 0.2.18 "@testing-library/react": ^16.3.0 "@types/node": 24.10.9 "@types/react": 19.2.7