SDK 2.1: platform toolkit + vesting engine
Concept · Platform kernel

Share platform rules without runtime coupling

Use one small module for ids, time, errors, events, environment guards, and privacy-safe fields.

v2.1.0 stable
SDK 2.1 is the stable release on the latest dist-tag. Install it with pnpm add @tokenops/sdk. The receipt-free and Safe create surface of /fhe-airdrop remains @beta.

@tokenops/sdk/kernel is the common vocabulary for application boundaries. It has no Node-only APIs and works in browsers, workers, and Node 22+, but it does use the platform globals crypto and performance. Install zod@^4.1.11: it is a mandatory runtime peer for this subpath, not an optional integration.

Ids, time, and clocks#

APIContract
newId / isId / IdSchemaUUID-v4 ids with one of the published ID_PREFIXES, such as org, dst, drw, or evt.
parseUnixSecondsAccepts a non-negative safe integer. Invalid values throw AppError(unix-seconds-invalid, validation).
systemClockWall time as whole Unix seconds through now(), and epoch milliseconds through nowMs().
FakeClockA deterministic Clock with advance(seconds) and advanceMs(ms); fractional milliseconds remain visible to nowMs().
monotonicMsA performance.now() value for durations and TTL budgets. Never persist or compare it with epoch time.

Versioned domain events#

Register an event schema once with defineEvent, then create envelopes by type. Registration snapshots and freezes the definition. The same type and version cannot be registered twice; getEvent(type) returns the greatest registered version. envelope parses the payload, copies the subject array, stamps evt_<uuid>, and reads the supplied clock.

domain/events.ts
ts
import { z } from "zod";
import {
  AddressSchema,
  FakeClock,
  IdSchema,
  UnixSecondsSchema,
  assertPayloadTypes,
  defineEvent,
  envelope,
  newCorrelationRef,
  parseUnixSeconds,
} from "@tokenops/sdk/kernel";

const DistributionCreated = z.strictObject({
  distributionRef: IdSchema("dst"),
  tokenAddress: AddressSchema.meta({ kernelType: "publicAddress" }),
  createdAt: UnixSecondsSchema,
});

assertPayloadTypes(DistributionCreated, ["publicAddress"]);
defineEvent({
  type: "distribution.created",
  version: 1,
  data: DistributionCreated,
  durable: true,
});

const clock = new FakeClock(parseUnixSeconds(1_800_000_000));
const event = envelope(
  "distribution.created",
  {
    distributionRef: "dst_550e8400-e29b-41d4-a716-446655440000",
    tokenAddress: "0x1111111111111111111111111111111111111111",
    createdAt: clock.now(),
  },
  {
    correlationRef: newCorrelationRef(),
    organizationId: "org_550e8400-e29b-41d4-a716-446655440000",
    actor: "per_550e8400-e29b-41d4-a716-446655440000",
    subject: ["dst_550e8400-e29b-41d4-a716-446655440000"],
  },
  clock,
);

An unknown type throws event-unknown; invalid data throws event-data-invalid; a duplicate version throws event-defined-twice. All three are non-retryable validation AppErrors.

Payload-type audit#

assertPayloadTypes walks a Zod 4 schema and rejects open or unclassified leaves. Kernel schemas mark ids and Unix seconds; enum, literal, boolean, null, and undefined are closed leaves. Pass application-defined metadata kinds only when your schema registry deliberately defines them, as the address transform does above.

One error shape across bundles#

AppError carries a stable code, category, retryability flag, and typed details. Use isAppError instead of instanceof: its symbol brand works when two bundles contain separate class copies.

api/errors.ts
ts
import {
  AppError,
  httpStatus,
  isAppError,
  sanitize,
} from "@tokenops/sdk/kernel";

export function toResponse(error: unknown): Response {
  const appError = isAppError(error)
    ? error
    : new AppError("internal-error", "unavailable", true);

  const { clean } = sanitize({
    code: appError.code,
    category: appError.category,
    retryable: appError.retryable,
    secret: "dropped",
  });

  return Response.json(
    { error: appError.code, details: appError.details, telemetry: clean },
    { status: httpStatus(appError.category) },
  );
}
CategoryHTTP
validation400
unauthenticated401
forbidden / step-up-required403
not-found404
conflict409
refused-by-chain / refused-by-provider422
rate-limited429
unavailable503

Telemetry allowlist#

FIELD_CLASSES is a frozen name-to-class map. sanitize(record) preserves classified fields in input order and reports every dropped name. If any person_id field survives, it also removes all public_address fields, preventing a person id and on-chain address from being linked in one record. It classifies names only; the observability exporter separately keeps primitive values.

  • assertDimensions throws dimension-not-allowed and lists refused names.
  • ERROR_DETAIL_KEYS adds bounded error-only keys such as path, expected, and limit.
  • Adding a field name is a privacy decision; unknown names are dropped, never passed through optimistically.

Environment and correlation guards#

config/environment.ts
ts
import { assertEnvironment } from "@tokenops/sdk/kernel";

const allowed = {
  chainIds: [1, 11_155_111],
  addresses: ["0x1111111111111111111111111111111111111111"],
  relayerUrls: ["https://relayer.example.com"],
} as const;

assertEnvironment(allowed, {
  chainIds: [11_155_111],
  addresses: ["0x1111111111111111111111111111111111111111"],
  relayerUrls: ["https://relayer.example.com"],
});

assertEnvironment compares every used chain id, address, and relayer URL with the supplied lists. Addresses compare case-insensitively; relayer URLs compare exactly. The first miss throws environment-mismatch as a non-retryable unavailable error.

Correlation refs are 26-character lower-case base32 values. fromTraceparent derives one from a valid W3C version-00 header or creates a fresh ref; toTraceparent validates both the ref and a non-zero 16-hex-digit span id. shortRef is only the first eight characters for display, not a durable identifier.

See also