Skip to main content

Keys

Every identity is created with one key — the owner key, generated at onboarding. Everything past that is optional: you can attach more keys, each with its own role on a spectrum from one that can only authenticate REST calls, through one that raises transfers, one that confirms them, and back to the owner key itself, which is the only one that can grant or remove any of the others.

One call, listLedgerKeys(), is the whole picture: every key attached to an identity, its type, its role, the REST scopes it carries and the on-ledger spend policy it's bound by. addKey/removeKey are the one way to change it. There is no second keychain. A separately-registered "API key" used to live in a table of its own, outside the ledger; that store has been retired and a ledger key does everything it did and more — see One keychain, and what replaced the old one.

The spectrum of roles​

RoleStakeCan move fundsTypical holder
offledger0, labelled offledgerNever. Both Varnir.CryptoTransfer and Varnir.Swap refuse a signer labelled offledger outright, whatever policy it happens to carry. Deliberate — see Off-ledger keys below.A gateway's own signing key, a webhook manager, anything that should authenticate to the REST API and nothing else
proposer0Raises a transfer, recorded as a proposal rather than executed. Cannot confirm anything, its own or anyone else's.An AI agent, or any process you trust to ask but not to decide — see AI agent payments
verifier80, labelled verifierConfirms other keys' proposals and nothing else — its own sends are refused outrightAn approval service
dual80, labelled treasuryBoth raises and confirms transfers — but never the one it raised itselfA peer service in a two-key arrangement
readonlylabelled readNoCustomer-service / read-only tooling
— (the owner key)100Sends unilaterally, no approval needed; can also confirm a proposal it did not raise; and is the only key that can add or remove another keyYou, generated at onboarding — ideally kept offline

No role may confirm its own proposal. That's enforced by key hash, not by role, so it holds however you arrange keys. The full propose/confirm mechanics — thresholds, polling for pending approvals, what result.proposal means — are Treasury approval; this page is about attaching and reading the keys, not the transfer flow some of them participate in.

Off-ledger keys: scopes without signing power​

An offledger key is a real, verifiable authority on the identity — it shows up in listLedgerKeys(), it can be removed, and it can carry REST scopes — but it holds zero on-ledger stake, and every fund-movement choke point refuses it before ever reaching policy enforcement: Varnir.CryptoTransfer's vote gate for a transfer, and Varnir.Swap's give-leg check for a swap. That refusal applies regardless of any policy the key carries: a policy attached to an offledger key is stored (for forward-compatibility and uniform storage) but inert — no contract path ever reads or enforces it there.

This is the point of the role, not a limitation of it. It's how you hand out a key that can create invoices, manage a webhook, or read balances through the API, with a hard, contract-level guarantee that it can never be the key that signs a transfer or a swap.

type: PQ-selectable, per key​

Every key — the owner key and everything added after it — has its own type: 'secp256k1', 'falcon-512', or 'ml-dsa-65'. VarnirClient.generateKey(type?) defaults to 'falcon-512', matching what a fresh identity onboards with:

import { VarnirClient } from '@varnir/chain-client';

const pqKey = VarnirClient.generateKey(); // falcon-512 — the default
const finalizedStandardKey = VarnirClient.generateKey('ml-dsa-65');
const classicalKey = VarnirClient.generateKey('secp256k1');

Falcon-512 and ML-DSA-65 are both post-quantum keys — see Quantum safety for exactly what "post-quantum key" does and doesn't mean here, and why both are on offer rather than just the smaller one. addKey's optional type field is not a way to choose the type — it's an assertion checked against the type actually encoded in publicKeyHex, and a mismatch is a VALIDATION error. The type is set by which key you generated, not by a field you pass to addKey.

A post-quantum key added as a secondary authority is verified with the right primitive automatically, including for header-signed API requests — see Any authority key can authenticate below. Nothing about scopes, policy or role depends on which scheme the key uses.

scopes and policy: what a key's restrictions actually hold​

Every key can carry a restrictions record on the ledger, shaped {version: 1, scopes?, policy?}:

  • scopes governs off-ledger REST access, drawn from the fixed vocabulary in the scope vocabulary below.
  • policy governs on-ledger signing — the spend controls below. Only meaningful on a key that can actually sign a transfer (proposer, verifier, dual); allowed on an offledger key too but inert there, as above.

An offledger key typically has scopes and no policy. A signing key can have policy, scopes, both, or neither — a signing key with no policy behaves exactly as keys always have. That's deliberate backward compatibility, not a gap.

The scope vocabulary​

Four names, declared once in API_KEY_PERMISSIONS and exported by both @varnir/chain-client and the scanner. Two of them are checked by a route:

ScopeEnforced today
invoices:createYes — required by POST /api/invoices
webhooks:manageYes — required by GET/PUT/DELETE /api/users/:id/webhook
payments:sendNo — accepted and stored, but no route checks it
treasury:approveNo — accepted and stored, but no route checks it

Grant only what a route actually enforces. The last two confer nothing, and are kept as reserved names rather than removed.

treasury:approve is the one most often misread. Treasury approval is real and it works — see Treasury approval — but it is enforced by a key's role and stake on the ledger, not by its scopes. Putting treasury:approve on a key approves nothing; giving it the verifier or dual role is what lets it confirm a proposal.

A scope the server does not recognise is not a way to invent a permission. scopes is checked by membership, so an unrecognised string matches no route's requirement and a key carrying only unrecognised scopes satisfies nothing.

The spend policy, and its raw-units convention​

import type { SpendPolicy } from '@varnir/chain-client';

const policy: SpendPolicy = {
version: 1,
// Per-asset caps, keyed '<network>:<token>' — note the COLON. addKey's own
// `approvalThreshold` option (for verifier/dual keys, see Treasury
// approval) uses a different, dot-separated key format — the two are not
// interchangeable.
rules: {
'TRX-NILE:#native': {
perTxMax: '5000000', // raw units; omit for no per-tx ceiling
periodCap: '20000000', // raw units spendable per rolling window
windowMs: 24 * 60 * 60 * 1000, // REQUIRED alongside periodCap
coSignOver: '1000000', // raw units; above this, hold for a co-sign.
// '-1' never requires one. OMITTING it does
// NOT mean "never" - it falls back to the
// deprecated top-level coSignOver.
},
},
// Recipient allowlist — identity ids or L1 addresses. Omit/empty = no restriction.
recipients: ['<allowed identity or address>'],
// true = only assets present in `rules` may move at all; `rules` doubles as the allowlist.
assetsAllowlisted: true,
};

Every amount above is a raw, smallest-on-chain-unit string, never a decimal one. TRX-NILE:#native is 6 decimals, so '5000000' is 5 TRX — write '5' there by mistake and the cap is five-millionths of a TRX, not five TRX. The SDK does not convert for you; read a network/token pair's decimals off listTokens() before turning a human amount into the string you pass here.

periodCap without windowMs is refused client-side — addKey throws VALIDATION rather than silently writing a rule that would sit on the ledger looking like a cap and enforcing nothing.

coSignOver belongs on the asset​

A co-sign threshold is compared against a raw amount, and raw units differ per asset, so one integer cannot be correct for two assets at once: '1000000' is 1 TRX (6 decimals) and 0.000000000001 ETH (18 decimals) at the same time. Set the threshold per asset, in AssetRule.coSignOver, next to perTxMax.

SpendPolicy.coSignOver — the top-level field — is deprecated but retained for policies written before the per-asset field existed. Varnir.CryptoTransfer reads the matched asset rule's coSignOver in preference and falls back to the top-level value only when that rule carries none — including for an asset with no rule at all, while assetsAllowlisted is false (with it true, an unruled asset is refused before the threshold is consulted, so there is nothing to fall back for).

Precedence is by presence, not by value: a per-asset coSignOver wins whenever it is set, including '-1', which exempts that one asset from an otherwise-armed policy. Omitting the field does not disarm the threshold; only '-1' does.

Keep the top-level field until you know the contract reads the new one

The per-asset read landed in the same Varnir.CryptoTransfer deployment as proposal void and expiry. An older deployment ignores AssetRule.coSignOver entirely and consults only the top-level value, so moving a threshold to the per-asset field and removing the top-level one leaves an older contract with no co-sign threshold at all — every amount passes through, unreported.

Until you have confirmed the deployment you are targeting reads the per-asset field, set both: the per-asset value for a current contract, and the top-level one as the floor an older contract still honours.

Write new policies with the per-asset field. The top-level one is unambiguous only for a policy that governs a single asset.

The scanner's policy editor does this for you: each asset rule's own Co-sign above field writes rules['<asset>'].coSignOver for the real threshold, the top-level coSignOver set to the lowest of them as the older-contract floor, and '-1' on every rule left blank — so a blank asset cannot inherit the top-level floor and pick up another asset's threshold in the wrong scale.

How the contracts enforce the policy​

The whole policy — perTxMax, periodCap, recipients, assetsAllowlisted and the co-sign threshold above — is enforced today by both Varnir.CryptoTransfer and Varnir.Swap, on the policy-carrying key's own give-leg — but not identically, because a swap can't be held half-done the way a transfer can:

  • Varnir.CryptoTransfer (send()): a policy refusal throws with one of POLICY_PER_TX_EXCEEDED, POLICY_ASSET_DENIED, POLICY_RECIPIENT_DENIED, or POLICY_PERIOD_CAP_EXCEEDED (see the SDK's error taxonomy). A coSignOver breach does not throw — like a treasury-gated send, it resolves successfully with result.proposal set and moves nothing until a confirming key signs off — and only for the next 24 hours; see Treasury approval for that half of the flow, expiry for the deadline, and voidTransfer for retiring one unconfirmed.
  • Varnir.Swap (create/fill/match): the same POLICY_PER_TX_EXCEEDED, POLICY_ASSET_DENIED and POLICY_PERIOD_CAP_EXCEEDED checks run against the give-leg — except recipients, which is deliberately not enforced here (a swap counterparty is the market-chosen other side of the trade, not a payee you allowlist). A coSignOver breach does throw, POLICY_COSIGN_REQUIRED — unlike a transfer, a swap is atomic, so there is no half-swap to hold in the propose/confirm flow while a co-signer is found; it's safe-rejected outright instead of executing without the required co-sign.

This enforcement is real on any network where the underlying contract code is deployed — the public testnet, as of this writing. A policy attached on a network without it is stored but not acted on.

Managing keys: addKey, removeKey, listLedgerKeys​

All three are called with the identity's own owner key — the only key that can change what another key may do.

import { VarnirClient } from '@varnir/chain-client';

const owner = new VarnirClient({ network: 'testnet', identity, privateKeyHex });

Add an off-ledger key, scoped to REST only:

const gatewayKey = VarnirClient.generateKey();

const umid = await owner.addKey({
publicKeyHex: gatewayKey.publicKeyHex,
role: 'offledger',
scopes: ['invoices:create', 'webhooks:manage'],
});

Add a signing key with a spend policy:

const agentKey = VarnirClient.generateKey();

await owner.addKey({
publicKeyHex: agentKey.publicKeyHex,
role: 'proposer',
policy, // the SpendPolicy above
// Optional. An absolute instant, zone required - see Key expiry below.
// Omit it and the key never expires.
expire: '2027-01-01T00:00:00Z',
});

Arm approval and add the confirming key — approvalThreshold is verifier/dual-only; see Treasury approval for its own (dot-separated) per-asset key format, which is not policy.rules's:

await owner.addKey({
publicKeyHex: approverKey.publicKeyHex,
role: 'dual',
approvalThreshold: '0',
});

Read every key back, straight off the ledger:

const keys = await owner.listLedgerKeys();
// [{ publicKey, type, stake, label, umid, role, isOwner, scopes, policy,
// expire, isExpired }, ...]

This returns every authority, including the owner key itself (flagged isOwner: true) — it is not filtered out. Read its own doc comment on read-after-write consistency before trusting a result taken immediately after addKey/removeKey: the authority list and its restrictions come from two independent reads, and a key can briefly come back present with scopes: []/policy: null right after being added with both — that's a stale read, not proof the restrictions weren't attached.

Remove a key:

await owner.removeKey(agentKey.publicKeyHex);

The owner's own key cannot be removed this way — the SDK checks client-side and throws VALIDATION rather than submitting a transaction that would resolve without changing anything (Varnir.Keys silently no-ops on an attempt to remove the stake-100 authority, which is why the SDK guards it rather than letting that through and reporting success for nothing).

Key expiry: a lifetime on the key itself​

A key can be issued with a deadline. Pass expire to addKey and the ledger engine stops honouring that authority once the instant passes — no revocation call, no scheduled job, nothing for you to remember to run.

await owner.addKey({
publicKeyHex: contractorKey.publicKeyHex,
role: 'proposer',
expire: '2027-01-01T00:00:00Z', // absolute instant, zone required
});

It is an absolute instant, not a TTL — a date the key dies on, not a number of days it lives for. Omitting expire means the key never expires; there is no default lifetime.

Enforcement is the engine's, not a Varnir contract's (Activeledger 4.8.0+). A lapsed authority is filtered out before any signature is checked, so every contract gets the control for free, and the failure comes back as EXPIRED rather than NOT_OWNER — "your key lapsed", not "that is not your key". See the error taxonomy.

The zone is mandatory, and the reason is not cosmetic​

expire must carry an explicit zone — Z, or a +HH:MM/-HH:MM offset. '2027-01-01T00:00:00Z' is accepted; '2027-01-01T00:00:00' is refused.

This is not pedantry about formats. ECMAScript parses a zone-less date-time as the reader's local time, and both the contract and the engine read the stored value back with new Date(...). Two nodes in different timezones would therefore lapse the same key at different instants, and so vote differently on one signature — a consensus split on a timer. The engine does not guard against this; Varnir.Keys does, at the only point the value is ever written, and the SDK refuses it client-side first so the mistake comes back as VALIDATION instead of a ledger rejection.

What is refused​

Four things. The SDK checks each one before signing, and Varnir.Keys re-checks every one of them and remains the authority:

RefusedWhy
A malformed or zone-less expire, or one that is ISO-shaped but not a real dateSee above — an ambiguous instant is a consensus hazard, not a typo
An expire not in the futureA key that expires on arrival could never sign anything. The contract compares against the consensus clock (this.cDate), the SDK against your wall clock; whichever notices first refuses, and both outcomes are right
An expire on the identity's own stake-100 owner keyThe owner key is the recovery path, and re-keying is itself owner-signed. Once it lapsed, nothing could ever re-key the identity
An expire on the identity's last authority that has noneThe lockout invariant — see below
The lockout invariant: at least one key with no expire field

Every identity must keep at least one authority carrying no expire field at all.

Read that precisely. It is not "one key that is not currently expired" — a far-future expiry does not satisfy it. 2099-01-01T00:00:00Z is still an expiring key, and an identity whose every authority expires is one that locks itself out on a timer, at a date nobody will be around to notice.

Adding a key whose public key is already an authority replaces that authority rather than adding a second one (Activeledger dedupes by key hash, last write winning) — so re-adding your one permanent key with an expire trips this too; the check looks at what survives the write, not the current list.

Both the SDK and Varnir.Keys refuse it and name the key. The engine also refuses it (setAuthorities throws "Operation denied this would expire every authority", commit raises 1236) but does not say which key, which is why the contract check exists in front of it.

Reading it back​

listLedgerKeys() returns two fields for this:

const keys = await owner.listLedgerKeys();
for (const k of keys) {
k.expire; // '2027-01-01T00:00:00Z', or null - null means NEVER EXPIRES
k.isExpired; // computed the way the engine computes it
}

isExpired is a transcription of the engine's own isAuthorityExpired, so it answers the same question the ledger will: no expire ⇒ false, an unparseable expire ⇒ false, otherwise now >= expire. Note the second one — a corrupt value still signs, matching the engine rather than silently disabling a key on a parse failure. It does not still authenticate: the REST surface refuses it, which is the one divergence in the callout below.

Two isExpired fields, opposite conventions — do not assume one from the other

VarnirLedgerKey.isExpired fails open: a key with no expire reads as not expired.

TransferProposal.isExpired fails closed: a proposal with no expiresAt reads as already expired.

Both are right, for opposite reasons. A proposal guards a pre-authorised payment, and an undated one is precisely the unbounded pre-authorisation the control exists to stop — so missing must mean dead. A key guards its own existence, and almost every authority on the network predates the field — so failing closed would report every identity as fully locked out. Do not carry the convention from one to the other.

There is a third reading, and it differs from VarnirLedgerKey.isExpired on exactly one case. The scanner's REST auth path (expiryVerdict, scanner-web/src/lib/key-expiry.ts) agrees on every verdict that decides whether a key works — absent expire ⇒ never expires, a date that has passed ⇒ expired, on the same >= boundary the engine uses — but an unparseable expire it refuses rather than passes. That is a position, not drift: the engine's fail-open there is a side effect of NaN losing every comparison, not a decision anyone took, and an authentication check that cannot establish a key is valid should not grant on it. A corrupt expire therefore leaves the key signing and locks it out of REST — the safer direction to be wrong in, and the only case in which the two disagree at all.

What the guarantee actually covers​

The guarantee is about signing. An expire stops the key signing ledger transactions, unconditionally and everywhere, because the engine — not any Varnir service — is what drops the lapsed authority. Nothing needs to be deployed or configured for that to hold, and no contract can opt out of it.

The off-ledger REST surface is a separate enforcement point, in the scanner rather than in the engine — and it enforces the same expire. A header-signed request from a lapsed authority is refused before the key's scopes are consulted at all, so the answer is "your key lapsed" rather than "your key lacks that permission", and ahead of the owner test too, so a stake-100 authority carrying an expire is refused rather than waved through on its stake. (Varnir.Keys refuses to date a stake-100 key in the first place, so that arm is belt-and-braces. The identity's own onboarding key is a separate matter again: it is recognised by hashing the public key, before any ledger read happens, so it never reaches this check at all.) The refusal is final, and there is nowhere left for it to fall through to — the ledger is the only place a key lives. It classifies as EXPIRED — see the error taxonomy — so a caller gets "re-date this key", not a generic rejection.

Two consequences of this being scanner code rather than engine code:

  • It lands up to 60 seconds late. The identity's key set is cached for that long, so a key can keep authenticating for up to a minute past its expire. This is the revocation window that was already accepted and documented for key removal — a removed key lingers exactly as long. Expiry inherits that window; it does not introduce one.
  • The ledger is only read when the request names an identity. An authority is a fact about an identity, so with none named there is no stream to look the signing key up on. The webhook routes take the identity from the URL path and are always covered. POST /api/invoices takes it from the owner field of the signed body, so a caller that omits owner reaches no ledger read — and is refused, not waved past. Omitting owner used to fall through to the retired table and authenticate there; now it is one of the two ways to reach the final refusal. Always send owner.

Like the rest of the header-auth path, this is a deployed service rather than the engine: see Not yet live everywhere for how to confirm it against the deployment you are targeting.

Needs a current Varnir.Keys, and it does not fail closed

Every refusal above is the contract's. Against an older deployed Varnir.Keys, addKey({ expire }) still writes the field — the transaction is accepted and the engine goes on to enforce a value that nothing validated. An unsafe lifetime therefore takes effect rather than being rejected.

Confirm the deployed contract is current before relying on the guards, rather than inferring from a call that succeeded.

Any authority key can authenticate​

Signing the X-Varnir-* REST headers (see The signing model) used to work only with an identity's own owner key, which still gets every permission, as before. It now also works with any other authority on the identity: the server resolves the signer against that identity's ledger key set and grants exactly what that key's own scopes say, no more. An offledger key added purely for REST access authenticates through this path the same way a proposer or dual key would — scoped down to whatever it was actually given.

Check the deployment you are targeting

Ledger-key REST auth is scanner code, not engine code, so which deployment you point at decides whether you get it. Against a scanner build that predates it, a secondary authority key signing a header-authenticated request is simply not recognised. Confirm it against your target deployment before depending on it, rather than inferring from a call that worked with the owner key.

One keychain, and what replaced the old one​

Keys used to be able to live in one of two unrelated places: the ledger authority list this page describes, or an api_keys table in Varnir's own database, written by a POST /api/api-keys endpoint that touched no ledger at all. That table is retired — its routes, its wallet UI cards and its SDK methods are gone — and every key is now a ledger authority, now that an authority's own expire (Activeledger 4.8.0+) covers the ttlDays lifetime the table used to provide, and more: it stops the key signing ledger transactions, not just REST calls.

RetiredUse instead
registerApiKey({label, permissions, ttlDays})addKey({role: 'offledger', scopes, expire})
listApiKeys()listLedgerKeys()
revokeApiKey(id)removeKey(publicKeyHex)
permissionsscopes
ttlDays, a day countexpire, an absolute instant with a zone
POST /api/api-keys, POST /api/api-keys/:id/revoke, GET /api/users/:id/api-keysNo REST equivalent. Key management is a ledger transaction, signed with the identity's own owner key

The replacement is wider, not like-for-like. A ledger key can carry a spend policy; it can be given a signing role rather than being confined to REST; its type is post-quantum by default, since generateKey() returns a Falcon-512 pair; and it is a fact recorded on the ledger rather than a row only Varnir can see.

What did not change: the X-Varnir-* header signing (buildApiKeyAuthHeaders), the scope vocabulary, and useApiKey/useOwnKey for choosing which key a client signs REST calls with. Each of those now takes an offledger ledger key where it used to take a registered one.

Three refusals the old store returned are gone with it: Unknown API key., This API key has been revoked. and This API key has expired. A key the ledger does not record as an authority on the identity a request names — and a request that names no identity at all — now gets one sentence instead:

This key is not an authority on the identity this request names, or the request named no identity at all.

Revocation is removeKey, and it inherits the same up-to-60-second cache window as expiry does above.

  • Treasury approval — the propose/confirm mechanics a proposer/verifier/dual key participates in.
  • AI agent payments — the same machinery, worked through for the case it fits best.
  • The signing model — what a key actually signs, for both ledger transactions and REST headers.
  • Quantum safety — what "post-quantum key" means here, precisely.