SDK 2.1: platform toolkit + vesting engine
Concept · Draft vault

Seal draft amounts to enrolled devices

Keep plaintext amounts in browser memory while your API stores only ciphertext, integrity tags, and key envelopes.

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/draft-vault encrypts draft token amounts in the browser with Web Crypto. It does not own storage, networking, authentication, or authorization: your application supplies an API port, a device key store, randomness, ids, a fingerprint dictionary, and a minimal telemetry counter. Install the mandatory zod@^4.1.11 runtime peer.

Wire application-owned persistence#

vault/create.ts
ts
import {
  createDraftVault,
  type DraftVaultDeps,
} from "@tokenops/sdk/draft-vault";

declare const api: DraftVaultDeps["api"];
declare const keyStore: DraftVaultDeps["keyStore"];
declare const fingerprintWords: readonly string[];
declare const telemetry: DraftVaultDeps["telemetry"];

function randomBytes(length: number): Uint8Array {
  return crypto.getRandomValues(new Uint8Array(length));
}

export const vault = createDraftVault({
  api,
  keyStore,
  subtle: crypto.subtle,
  randomBytes,
  newId: () => "drw_" + crypto.randomUUID(),
  words: fingerprintWords,
  telemetry,
});
DependencyResponsibility
apiRegister/list devices, move wrapped epoch keys, rotate epochs, and load/save versioned draft rows. The server never receives plaintext amounts or raw epoch keys.
keyStorePersist one device id and CryptoKeyPair per organisation; delete it when the API reports device-revoked.
subtle / randomBytesWeb Crypto operations and 12 fresh random bytes per sealed row.
wordsExactly 2,048 unique, non-empty words used to display a six-word device fingerprint.
newIdCreate a drw-prefixed row id when rowId is empty.
telemetryCount draft.integrity-mismatch and draft.restore-offered; never attach amounts.

Enrollment and key identity#

Enrollment creates a non-extractable ECDH P-256 device key pair and exports only its public SPKI. The six-word fingerprint is derived from SHA-256 of that public key. A device is:

  • pending when registered but missing the current epoch envelope;
  • enrolled when it can unwrap the current organisation epoch key;
  • locally none when no key-store record exists.

The first device creates the initial organisation key when the API asks for a first envelope. A newly pending device normally needs an existing enrolled device to confirm its fingerprint and call grantDevice. If no enrolled holder remains, the client may attempt a protected no-holder epoch rotation; a forbidden response leaves it pending.

grantDevice verifies the target public key against its recorded fingerprint, then wraps every epoch key the current device holds. A mismatch throws fingerprint-mismatch; a missing or revoked target throws not-found.

Seal and open rows#

vault/seal.ts
ts
import type {
  DraftRowInput,
  DraftVault,
  PlainRow,
  VaultContext,
} from "@tokenops/sdk/draft-vault";

declare const vault: DraftVault;
declare function saveRows(
  draftId: string,
  version: number,
  rows: DraftRowInput[],
): Promise<{ version: number }>;

const context: VaultContext = {
  orgId: "org_550e8400-e29b-41d4-a716-446655440000",
  draftId: "dst_550e8400-e29b-41d4-a716-446655440000",
  version: 7,
  token: "0x1111111111111111111111111111111111111111",
  decimals: 6,
};

const enrollment = await vault.enrollDevice(context.orgId);
if (enrollment.state !== "enrolled") {
  throw new Error("another enrolled device must grant this device");
}

const rows: PlainRow[] = [
  { rowId: "", personRef: "employee-42", amount: 1_250_000n },
];

const sealed = await vault.sealRows(context, rows);
const saved = await saveRows(context.draftId, context.version, sealed);

Each organisation epoch uses an AES-256-GCM draft key. The amount is an eight-byte big-endian unsigned integer; encryption uses a fresh 12-byte IV. Additional authenticated data binds orgId, draftId, row id, lower-case token address, decimals, sealed version, and key epoch. A separate HMAC-SHA-256 integrity tag is derived from the epoch key with HKDF. Changing identity, version, token, decimals, ciphertext, or tag makes the row fail closed.

sealedVersion is always context.version + 1, matching the version your API should commit when it saves the returned inputs. personRef remains plaintext metadata; do not place sensitive personal data in it.

vault/open.ts
ts
import type {
  DraftRow,
  DraftVault,
  VaultContext,
} from "@tokenops/sdk/draft-vault";

declare const vault: DraftVault;
declare const context: VaultContext;
declare const storedRows: DraftRow[];

const result = await vault.openRows(context, storedRows);

for (const row of result.opened) {
  console.log(row.rowId, row.amount);
}
for (const failure of result.failed) {
  console.warn(failure.rowId, failure.reason);
}

Opening is intentionally partial. Rows without a matching envelope return no-envelope; corrupt, rebound, or future-version rows return integrity-mismatch. Valid rows still open. With no local device, the whole call throws device-not-enrolled.

Hard workload and amount limits#

LimitBehavior
10,000 rowsseal, open, preview, and apply reject larger arrays with request-invalid. Restore also refuses a preview that would exceed 10,000 existing plus new rows.
0..2^64 - 1 base unitssealRows and applyRestore throw amount-out-of-range outside the unsigned 64-bit range.
500 reseals per pageresealPage rewrites at most 500 decryptable, unlocked rows from older epochs.

Rotate, then reseal#

vault/rotate.ts
ts
import type {
  DraftVault,
  VaultContext,
} from "@tokenops/sdk/draft-vault";

declare const vault: DraftVault;
declare const context: VaultContext;

const rotation = await vault.rotateEpoch(
  context.orgId,
  "device-revoked",
);

let remaining = rotation.rowsToReseal;
let version = context.version;

while (remaining > 0) {
  const page = await vault.resealPage({ ...context, version });
  remaining = page.remaining;
  version = page.version;
  if (page.resealed === 0) break;
}

Rotation creates a fresh epoch key and wraps it to every currently enrolled device. Public reasons are member-removed, device-revoked, and on-request. The server returns the new epoch and rows needing migration.

resealPage loads the current draft, skips locked and deployed rows, opens an old row with its old envelope, and reseals it under the current epoch. Rows whose old key is unavailable or whose integrity fails are skipped. Save uses optimistic context.version; feed the returned version into the next page and handle API conflicts in the application.

Restore is preview first#

vault/restore.ts
ts
import type {
  DraftRow,
  DraftVault,
  VaultContext,
} from "@tokenops/sdk/draft-vault";

declare const vault: DraftVault;
declare const context: VaultContext;
declare const storedRows: DraftRow[];

const csv = [
  "rowId,personRef,amount",
  "drw_550e8400-e29b-41d4-a716-446655440000,employee-42,1.25",
  ",employee-99,2.00",
].join("\n");

const preview = vault.previewRestore(context, csv, storedRows);
const accepted = preview.rows.filter(
  (row) => row.status === "filled" || row.status === "new",
);

const resealedInputs = await vault.applyRestore(context, {
  rows: accepted,
});

CSV is deliberately narrow: the exact header is rowId,personRef,amount, there is no quoted-field parser, known rows must name an existing row id, and an empty row id creates a new row. Decimal text is converted to base units using the context decimals and may not exceed uint64.

Preview statusMeaning
filledExisting editable row, matching personRef, amount ready to reseal.
newEmpty row id; applyRestore assigns a new drw id.
lockedLocked or deployed row; amount remains null and cannot be restored.
missingAn editable stored row had no restore entry.
mismatchedThe row id exists but personRef differs; do not apply blindly.

applyRestore seals only filled and new rows. Your UI decides which preview rows to include, records that decision, and then saves the returned inputs through the API.

Errors to design for#

CodeResponse
no-draft-keyCurrent device lacks the current epoch key; conflict, enroll/grant or restore.
device-not-enrolledNo local device key; enroll this browser.
device-revokedAPI error is rethrown after local keys and cached epoch keys are deleted.
integrity-mismatchDo not guess an amount; offer restore and retain evidence.
csv-invalidShow the 1-based details.line and keep the original draft unchanged.
request-invalidRow workload exceeded the hard bound or input shape is invalid.
amount-out-of-rangeAmount cannot fit the vault and FHE euint64 representation.

See also