0%

Docs

The reference. Every call, every field, every code the SDK can hand back — and the HTTP surface underneath it, for fleets that do not run JavaScript.

  • SDK v0.4.2
  • SPEC v0.4
  • MIT
  • NODE 20+

Overview

WHAT THE SDK ACTUALLY DOES

The SDK is a thin shell around four verbs. It never generates a key, never holds a balance, and never asks a server for permission — it asks the machine to sign, and hands the signature on.

It does not own the keyThe signer lives in the machine's secure element. The SDK asks it to sign a challenge and forwards the result. Uninstall the SDK and the machine's identity is unchanged.
It does not hold fundsA grant opens a payment channel the machine itself closes. Katana is never a counterparty to a settlement, which is why there is no account to create.
It fails closedEvery refusal listed under Error codes stops the call rather than falling back. There is no degraded mode where an unsigned reading settles at an estimate.
It is offline-tolerant, not offline-blindClaims net locally while the chain is unreachable and close when it returns. Ticks that were never signed are dropped, not reconstructed.

Install

NO ACCOUNT · NO KEY TO PASTE

The package ships ESM and CJS entry points and its own type declarations. It has one runtime dependency — a COSE signer shim — and no native build step.

  • npmnpm i @katana/sdk
  • pnpmpnpm add @katana/sdk
  • bunbun add @katana/sdk
  • denodeno add jsr:@katana/sdk
node>= 20.11Needs `WebCrypto` and `AbortSignal.timeout`.
typescript>= 5.4Optional. Types are emitted, not inferred at build.

Environments

TWO, AND THEY DO NOT SHARE IDENTITIES

A machine attested on testnet is not attested on mainnet. Manufacturer roots are published per network, so a test key can never settle real work by accident.

NetworkChain IDEndpointSettlementFaucet
mainnetkatana:1https://rpc.katana.shreal
testnetkatana:11https://rpc.test.katana.shvaluelesshttps://faucet.test.katana.sh
Default
The SDK reads `KATANA_NETWORK` when `network` is not passed to `attach()`. It defaults to `mainnet`, because a fleet that silently ran against testnet for a week is a worse failure than one that refused to start.

Quickstart

THE SHORTEST PROGRAM THAT SETTLES

Attach, take a grant, open against a dock, let the meter run, close. Everything else in this manual is a detail of one of those five lines.

ts
import { attach, secureElement } from "@katana/sdk";

const machine = await attach({
  did: "did:katana:p256:KL-0117",
  signer: secureElement(),
});

await machine.attest();

const grant = await machine.grant({
  scope: ["arm.lift", "arm.place"],
  ceiling: { wh: 8_000, currency: "usd", max: 4.0 },
  expiry: "+45m",
});

const session = await grant.open({ dock: "dock-01" });

for await (const tick of session.meter()) {
  telemetry.push(tick);          // { wh, ts, sig }
}

const receipt = await session.close();
console.log(receipt.amount.usd); // 0.4521
Then
Run it against testnet first by exporting `KATANA_NETWORK=testnet`. The dock name is the same on both networks; the attestation behind it is not.

The object model

FIVE NOUNS, AND WHAT OWNS WHAT

Each object below is created by the one above it and cannot outlive it. That is the whole hierarchy — there is no registry, no session store, and nothing to garbage-collect on our side.

Machineattach()A signer plus a DID. Holds no state beyond the key handle; safe to create per process.
Attestationmachine.attest()A COSE_Sign1 proof that the key is real and its manufacturer root is current. Cacheable for its stated lifetime.
Grantmachine.grant()Scoped, capped, time-boxed authority. The only thing on the machine that can authorise an actuator call.
Sessiongrant.open()One metered engagement with one dock. Emits ticks; closes into a receipt.
ClaiminternalA signed running total inside a session. Monotonic, netted off-chain, and never exposed as a mutable object.
Machine
└── Grant                 scope, ceiling, expiry
    └── Session           one dock, one engagement
        ├── Tick          { wh, ts, sig }   ≤ 1s apart
        └── Claim         signed running total
            └── Receipt   the close, on chain

Session lifecycle

SIX STATES · ONE DIRECTION

A session only moves forward. There is no pause, and nothing re-opens — a dock that drops out ends the session and the next engagement is a new one.

openingMutual attestation with the dock→ open, or E_ATTEST
openTicks accruing against the grant→ closing, draining, or E_CEILING
drainingChain unreachable; claims netting locally→ open, or closing
closingFinal claim submitted→ settled
settledReceipt issued, channel closedterminal
voidedGrant revoked or expired mid-sessionterminal; prior ticks still settle
Watch
`voided` is the state most people forget to handle. Revocation ends *authority*, not accounting: the watt-hours already drawn settle normally, and the receipt arrives as it would have.

Scopes

EXPLICIT, AND NO WILDCARD ON MOTION

A scope names one actuator verb. `arm.*` does not compile, because nobody has ever meant it — a fleet that wants two verbs asks for two verbs.

ScopeGrants
arm.liftVertical actuation on a fixed arm
arm.placeHorizontal actuation and release
arm.weldArc actuation. Requires a second, live attestation
drive.moveWheeled or tracked translation
deck.holdLoad retention while driving
dock.drawRequesting power from a dock
Exception
`arm.weld` is the one scope that needs a second, live attestation at open time rather than a cached one. Anything that can start a fire does not run on a proof from an hour ago.

attach()

SDK

Binds the SDK to a key that already exists in the machine's secure element. Makes no network call.

function attach(options: AttachOptions): Promise<Machine>
didstringrequiredThe machine's identifier. Must resolve to the same curve the signer uses.
signerSignerrequiredA handle to the secure element. `secureElement()` covers the common case.
network"mainnet" | "testnet"$KATANA_NETWORKWhich chain and manufacturer root set to use.
timeoutnumber8000Milliseconds before any single request aborts.
fetchtypeof fetchglobalThis.fetchOverride for proxied or instrumented environments.
Returns
A `Machine`. Cheap to construct — no I/O happens until `attest()`.
Throws
  • E_SIGNERThe secure element did not answer, or answered with a different curve than the DID declares.
ts
const machine = await attach({
  did: "did:katana:p256:KL-0117",
  signer: secureElement(),
  network: "testnet",
});

machine.attest()

SDK

Proves the key is real and its manufacturer root is current. The result is cacheable for its stated lifetime.

machine.attest(options?: AttestOptions): Promise<Attestation>
forcebooleanfalseSkip the cache and re-prove even if the held attestation is still valid.
audiencestringBind the proof to one verifier so it cannot be replayed elsewhere.
Returns
`{ ok, chain, expiresAt, cose }` — `chain` is `"verified"` or `"stale"`; a stale root is a refusal, not a warning.
Throws
  • E_ATTESTThe key answered but the chain did not verify. Usually a manufacturer root that has rotated.
ts
const proof = await machine.attest({ audience: "dock:dock-01" });
// { ok: true, chain: "verified", expiresAt: 1782416671, cose: "…" }

machine.grant()

SDK

Issues scoped, capped, time-boxed authority. The grant is the only thing that can authorise an actuator call.

machine.grant(options: GrantOptions): Promise<Grant>
scopestring[]requiredActuator verbs, named in full. Wildcards are rejected at issue.
ceilingCeilingrequired`{ wh, currency, max }`. The meter stops at whichever bound is hit first.
expirystring | numberrequiredRelative (`"+45m"`) or a unix timestamp. Hard maximum 24h.
regionstringRestricts the grant to docks in one region. Absent means anywhere.
memostringUp to 128 bytes, carried into the receipt. Not encrypted.
Returns
A `Grant` that emits `revoked` and dies on its own at `expiry`.
Throws
  • E_SCOPEA scope entry is unknown, or contains a wildcard.
  • E_ATTESTNo valid attestation is held and one could not be obtained.
ts
const grant = await machine.grant({
  scope: ["arm.lift", "arm.place"],
  ceiling: { wh: 8_000, currency: "usd", max: 4.0 },
  expiry: "+45m",
  region: "eu-west",
});

grant.on("revoked", ({ by, at }) => machine.park({ reason: "revoked" }));

grant.open()

SDK

Opens a metered session against one dock, by mutual attestation. Both sides sign the start reading.

grant.open(options: OpenOptions): Promise<Session>
dockstringrequiredDock identifier, e.g. `"dock-01"`. Must be inside the grant's region.
signalAbortSignalAborts the handshake. Does not close an already-open session.
livebooleanfalseForce a fresh attestation instead of a cached one. Implied by `arm.weld`.
Returns
A `Session` in state `open`, with `t0` already signed by both parties.
Throws
  • E_SCOPEThe dock is outside the grant's region, or offers no scope the grant covers.
  • E_EXPIREDThe grant died between issue and open.
ts
const session = await grant.open({
  dock: "dock-01",
  signal: AbortSignal.timeout(5_000),
});

session.meter()

SDK

An async iterable of signed deltas. Yields at most one second apart while the session is open.

session.meter(): AsyncIterable<Tick>

Takes no arguments.

Returns
`{ wh, ts, sig }` per tick. Iteration ends when the session leaves `open`, including on `voided`.
Throws
  • E_CEILINGThe meter reached the grant's ceiling. Expected, not exceptional — close and re-grant.
ts
for await (const tick of session.meter()) {
  // a tick more than a second late is dropped, never interpolated
  telemetry.push(tick);
  if (tick.wh > 500) console.warn("draw spike", tick);
}

session.close()

SDK

Submits the final claim and settles the channel in one transaction, whatever the tick count.

session.close(options?: CloseOptions): Promise<Receipt>
waitbooleantrueResolve only once the settlement lands. `false` returns as soon as the claim is signed.
deadlinenumber30000Milliseconds to wait for the chain before returning a `draining` receipt.
Returns
A `Receipt` carrying the totals, the transaction, and the proof that produced it.
Throws
  • E_EXPIREDThe grant died before the close and no signed claim exists to settle.
ts
const receipt = await session.close();
// {
//   session: "sx_01J8Y…", total_wh: 3417,
//   amount: { usd: 0.4521 }, ticks: 412,
//   settled: { tx: "0x9c1f…", at: 1782413071 },
//   proof: { machine, dock, grant, sig }
// }

grant.revoke()

SDK

Ends authority immediately and publicly. Accounting for work already done is unaffected.

grant.revoke(reason?: string): Promise<void>
reasonstringCarried into the log and the receipt. Up to 128 bytes.
Returns
Resolves once the revocation is published. Any open session moves to `voided`.
Throws
  • E_EXPIREDThe grant had already died. Revoking a dead grant is a no-op, not an error, unless it never existed.
ts
await grant.revoke("axis 4 torque outside envelope");
// authority ends at the next actuator call, not at the next sync

Types

THE SHAPES, IN FULL
ts
type Signer = {
  curve: "p256" | "ed25519";
  sign(payload: Uint8Array): Promise<Uint8Array>;
};

type Attestation = {
  ok: boolean;
  chain: "verified" | "stale";
  expiresAt: number;          // unix seconds
  cose: string;               // COSE_Sign1, base64url
};

type Ceiling = {
  wh: number;                 // hard energy cap
  currency: "usd" | "eur";
  max: number;                // hard spend cap
};

type Tick = {
  wh: number;                 // delta since the previous tick
  ts: number;                 // unix millis, dock clock
  sig: string;                // dock signature over (session, wh, ts)
};

type Receipt = {
  session: string;
  total_wh: number;
  amount: Record<Ceiling["currency"], number>;
  ticks: number;
  settled: { tx: string; at: number } | null;   // null while draining
  proof: {
    machine: string;          // did
    dock: string;             // did
    grant: string;
    sig: string;
  };
};

type SessionState =
  | "opening" | "open" | "draining"
  | "closing" | "settled" | "voided";

HTTP API

FOR FLEETS THAT DO NOT RUN JAVASCRIPT

The SDK is a client for this surface and nothing more. Every call below is what the SDK sends; a machine that can do HTTPS and COSE needs no SDK at all.

MethodPathDoes
POST/v0.4/attestVerify a COSE_Sign1 proof against the current root set
POST/v0.4/grantsIssue a scoped grant
DELETE/v0.4/grants/{id}Revoke a grant
POST/v0.4/sessionsOpen a session against a dock
POST/v0.4/sessions/{id}/ticksSubmit a batch of signed ticks
POST/v0.4/sessions/{id}/closeSubmit the final claim and settle
GET/v0.4/sessions/{id}Read state and the running total
GET/v0.4/docks/{id}Read a dock's DID, region and port count
Auth
Every request carries `Authorization: COSE <base64url>` — a signature over the request body and a nonce from `/v0.4/nonce`. There are no API keys, because there is no account for one to belong to.
bash
curl -s https://rpc.katana.sh/v0.4/sessions \
  -H "Authorization: COSE $(katana sign --body @open.json)" \
  -H "Content-Type: application/json" \
  -d @open.json

# {"session":"sx_01J8Y…","state":"open","t0":1782411204}

Webhooks

FOUR EVENTS · SIGNED THE SAME WAY EVERYTHING ELSE IS

Deliveries are retried for 24 hours with exponential backoff, then dropped. A missed delivery is never a lost settlement — the receipt is on chain regardless.

meter.tick{ session, wh, ts, sig }Batched, at most once a second per open session.
grant.revoked{ grant_id, by, at, reason }Fires before the next actuator call is refused, not after.
session.settled{ session, total_wh, amount, tx }The channel closed. This is the receipt.
machine.parked{ machine, reason }Your own handler ran. Nothing about this touched the chain.
Verify
Verify `X-Katana-Signature` over the raw body before parsing it. The signing key is the network's, published at `/v0.4/keys`, and it rotates on the spec's schedule.
ts
import { verifyWebhook } from "@katana/sdk";

app.post("/hooks/katana", raw(), async (req, res) => {
  const ok = await verifyWebhook({
    body: req.body,                       // Buffer, not parsed
    signature: req.header("X-Katana-Signature"),
    network: "mainnet",
  });
  if (!ok) return res.status(400).end();

  const event = JSON.parse(req.body.toString());
  if (event.type === "session.settled") await reconcile(event.data);
  res.status(204).end();
});

CLI

SHIPPED WITH THE PACKAGE

The CLI is the SDK with a terminal in front of it. It signs with the same secure element and refuses in the same places.

CommandDoes
katana attestProve the local machine and print the COSE blob
katana grant --scope arm.lift --ceiling 8kWh --expiry 45mIssue a grant and print its id
katana open --dock dock-01 --grant gr_…Open a session and stream ticks to stdout
katana close --session sx_…Settle and print the receipt as JSON
katana revoke --grant gr_… --reason '…'End authority now
katana sign --body @file.jsonProduce an Authorization header for the HTTP API
katana doctorCheck signer, clock drift and root freshness
First run
`katana doctor` is worth running before anything else on a new machine — clock drift over thirty seconds is the single most common cause of an `E_ATTEST` that looks like a key problem.

Error codes

EVERY REFUSAL THE SDK CAN RAISE

Errors are `KatanaError` instances with a `code`, a `retryable` flag and, where one exists, a `hint`. Nothing here falls back to a degraded path.

E_ATTESTnoSignature did not verify. Check the manufacturer root is current and the clock is within thirty seconds.
E_SIGNERnoThe secure element did not answer, or its curve disagrees with the DID.
E_SCOPEnoThe call is outside the grant. Widen the scope at issue — never at the call site.
E_CEILINGnoThe meter reached the ceiling. Expected. Close the session and open a new grant.
E_EXPIREDnoThe grant died. Time-boxed authority did its job; there is nothing to recover.
E_DOCKyesThe dock is unreachable or full. Retry, or open against another dock in region.
E_CHAINyesSettlement could not land. The session drains and closes when the chain returns.
E_CLOCKnoMachine and dock disagree by more than thirty seconds. Run `katana doctor`.
E_NONCEyesA replayed or stale nonce. Fetch a fresh one and re-sign.

Limits

THE NUMBERS THAT WILL STOP YOU
Meter tick interval≤ 1 sA later tick is dropped, never interpolated.
Grant expiry≤ 24 hLonger expiries are rejected at issue.
Scopes per grant≤ 16Rejected at issue with `E_SCOPE`.
Open sessions per grant1A second `open()` throws until the first closes.
Ticks per session86,400The session force-closes and settles what it has.
Clock drift≤ 30 s`E_CLOCK` at open, not silently absorbed.
HTTP requests120 / min / machine`429` with `Retry-After`. Ticks are exempt.
Webhook retries24 hThen dropped. The receipt is still on chain.
Memo size128 BTruncated at issue, not rejected.
Attestation cache1 hPast that, `attest()` goes back to the network.

Recipes

THREE THINGS PEOPLE ASK FOR

Rotate a key without breaking lineage

Rotation keeps history. The new epoch cites the old key, so an auditor can walk the chain backwards without the manufacturer's help.

ts
const next = await machine.rotate({
  reason: "scheduled",
  // the previous public half is cited, never discarded
});

// old attestations stay verifiable; new grants use the new epoch
await machine.attest({ force: true });

Split one job across two machines

Two grants, one memo. Each machine settles its own half, and the shared memo is what reconciles them later.

ts
const memo = `job:${jobId}`;

const [lift, place] = await Promise.all([
  armA.grant({ scope: ["arm.lift"],  ceiling, expiry: "+20m", memo }),
  armB.grant({ scope: ["arm.place"], ceiling, expiry: "+20m", memo }),
]);

// each settles independently; the memo joins them in the ledger
const receipts = await Promise.all([runLift(lift), runPlace(place)]);

Reconcile a period against the chain

Never reconcile against your own telemetry. The receipts carry the proof; your database carries whatever your process wrote down.

ts
const settled = await katana.receipts.list({
  machine: "did:katana:p256:KL-0117",
  from: "2026-08-01",
  to: "2026-09-01",
});

for (const r of settled) {
  const local = await db.receipts.find(r.session);
  if (!local) report.missing.push(r);
  else if (local.total_wh !== r.total_wh) report.drift.push({ r, local });
}

Versioning

SLOW, DATED, AND BORING ON PURPOSE

The SDK follows semver. The protocol does not — it is dated, and two spec versions run side by side for a full quarter before the older one is refused.

SDK minorquarterlyAdditive only. New optional fields, new scopes, new error codes.
SDK majorrareOnly when a default changes — a new required field, or a default that flips.
Spec versiondated`/v0.4/` in every path. A new spec gets a new prefix; the old prefix keeps answering.
Overlap window1 quarterBoth prefixes accept traffic. After that, the old one returns `410`.
Root rotationannualAnnounced 90 days ahead at `/v0.4/keys`. Cached attestations survive it.

Glossary

THE WORDS THIS MANUAL USES PRECISELY
AttestationA COSE_Sign1 proof that a key is real and its manufacturer root is current. Not an authorisation.
CeilingThe hard energy and spend caps on a grant. The meter stops at whichever is reached first.
ClaimA signed running total inside a session. Monotonic, netted off-chain.
DockA meter with a key. It signs the start reading, the deltas and the close.
DriftDisagreement between the machine's clock and the dock's. Over thirty seconds it is an error.
GrantScoped, capped, time-boxed authority. The only thing that can authorise an actuator call.
ReceiptWhat a close returns: totals, transaction and the proof that produced them.
ScopeOne actuator verb, named in full. Never a wildcard.
Secure elementThe die the key is sealed into at manufacture. The key never leaves it.
TickOne signed energy delta, at most a second after the last.
VoidedA session whose grant was revoked or expired. Prior ticks still settle.

$KATANA

ONE NUMBER SETTLED · THE REST UNPUBLISHED

A supply of 500,000,000 $KATANA, listed on pons. The supply figure and the listing are what exist; the rows below marked unpublished are genuinely undecided rather than withheld, and this page will not guess at them.

500,000,000TOTAL SUPPLY
Total supply500,000,000The one figure fixed so far.
ListingLive on ponsContract 0x44559c0f…27958a. Linked from the landing page and below.
DistributionUnpublishedNo allocation, split or recipient set is decided.
Unlock scheduleUnpublishedNo dates, cliffs or vesting have been set.
Role in settlementUnpublishedThe rails in this manual settle without it today.
Note
None of this is an offer, a solicitation, or investment advice, and nothing here is a claim about price or return. When there is something further to say it will be published here, dated and versioned like the rest of this manual.
KAI, keeper of the ledgerKAI
KEEPER OF THE LEDGER

Nothing in here is advice. It is the shape of the thing, written down... If a field is missing, the call will tell you so before I do.