The Varnir SDK for TypeScript
@varnir/chain-client is the reference SDK: every other language is a port of
it, and it is what the web and mobile wallets, the browser extension and the
playground run on. This page walks through the whole flow with it, step by step. If you are
choosing a language, start at Getting started.
The whole flow
Everything an integration needs — create an identity, put it on the ledger, get a wallet, read balances, send, raise an invoice, add a scoped key, and set a deposit callback — is on one class.
import {VarnirClient, VarnirChain} from '@varnir/chain-client';
import {generateRecoveryPhrase, derivePrivateKeyFromPhrase} from '@varnir/chain-client/keys';
const phrase = generateRecoveryPhrase();
const {privateKeyHex} = derivePrivateKeyFromPhrase(phrase);
const client = await VarnirClient.onboard({network: 'testnet', privateKeyHex});
const wallet = await client.getWallet(VarnirChain.TronNile);
const invoice = await client.createInvoice({
wallets: [wallet],
name: 'First invoice',
amount: '10',
});
console.log(invoice.url); // https://scanner.varnir.site/pay/<id>
That is the whole flow. You never name a hostname, a deployed contract's stream
id, or a transaction shape — 'testnet' is the entire configuration.
Onboarding and wallet assignment are signed in your own process and posted
directly to a chain node. createInvoice talks to Varnir's API, but
authenticates with a request signature, not a transmitted secret. See
The signing model.
The complete runnable version is
packages/chain-client/examples/wallet-and-invoice-flow.ts in the monorepo, and
every snippet on the playground is this same
API running live in a browser tab.
Install
One package, two entry points you will use:
// The SDK.
import {VarnirClient} from '@varnir/chain-client';
// Recovery phrases and key derivation. A subpath because it pulls in bip39,
// which a consumer that only signs transactions has no use for.
import {generateRecoveryPhrase, derivePrivateKeyFromPhrase} from '@varnir/chain-client/keys';
There are two more subpaths, @varnir/chain-client/node and
@varnir/chain-client/web, holding the platform-specific direct-to-node
transports (NodeTransport / WebTransport). You do not need either for
anything on this page.
1. Get a key
const phrase = generateRecoveryPhrase(); // 12 words
const {privateKeyHex, publicKeyHex} = derivePrivateKeyFromPhrase(phrase);
Real BIP-32 derivation from a BIP-39 phrase, taking the master key exactly as every Activeledger SDK does.
publicKeyHex is the compressed (33-byte) form, 0x-prefixed.
Store the phrase. It is the only way back to this identity — there is no account recovery on Varnir's side, because Varnir never had the key.
For a server-side integration that will store the hex itself and does not need a
human-recoverable backup, VarnirClient.generateKey() returns a bare pair.
2. Onboard
const client = await VarnirClient.onboard({network: 'testnet', privateKeyHex});
client.identity; // '9935e4c1...'
This submits a real Varnir.Onboard transaction, signed locally and posted
straight to a chain node.
It is idempotent. A Varnir identity's stream id is deterministic from the public
key — sha256(publicKeyHex + 'identity'), which
VarnirClient.computeIdentity(publicKeyHex) computes for you with no network
call at all — so onboard checks whether the ledger already has it and submits
nothing if so. Calling it every time your process starts is fine.
If you already have an identity
Once you have onboarded, you hold both halves. Construct directly:
const client = new VarnirClient({
network: 'testnet',
privateKeyHex,
identity: '9935e4c1...',
});
The SDK does not re-derive the identity from the key here, on purpose. It is derivable, but only for a key onboarded through this exact path, and treating a locally computed id as authoritative is how you end up holding an identity that exists nowhere on the ledger. If you have both, pass both.
await client.identityExists() is a ground-truth read of the ledger's own
document store if you want to confirm it.
3. Get a wallet
import {VarnirChain} from '@varnir/chain-client';
const wallet = await client.getWallet(VarnirChain.TronNile);
// { id, address, chain: 'niles', symbol: 'TRX', keyMethod: 'standard' }
Three chains today: VarnirChain.Sepolia (Ethereum Sepolia, native ETH),
VarnirChain.TronNile (Tron Nile, native TRX) and VarnirChain.BnbTestnet
(BNB Smart Chain testnet, native BNB; 'tbnb'). Base Sepolia
('base-sepolia'), Arbitrum Sepolia ('arb-sepolia') and Polygon Amoy
('polygon-amoy') join VarnirChain when they open for deposits; until then
the SDK refuses them. VarnirChain is a real
TypeScript enum, so your editor autocompletes it and a typo fails at compile
time instead of coming back as "Unknown chain" from the network — but it's
purely a named alternative to the raw string: getWallet('niles') and
getWallet(VarnirChain.TronNile) do exactly the same thing, and everything
that returns a chain (a wallet, a balance) still gives you back the plain
string ('niles', 'sep', 'tbnb'), not the enum.
getWallet returns the wallet this identity already has on that chain, or gets
it one. Getting one is two steps: Varnir's gateway hands out an unassigned
address from a pool it pregenerates ahead of demand, and a
Varnir.AssignOwner transaction signed with your own key establishes
ownership. The gateway never signs that — it hands out ids, it does not decide
who owns one.
createWallet(VarnirChain.TronNile) always takes a fresh one instead of
reusing an existing wallet. Both accept {keyMethod: 'mpc-tss'} for a wallet
generated by the threshold-signing protocol rather than the default
Shamir-split 'standard'.
Invoice creation checks wallet ownership against Varnir's own index, which the
gateway feeds asynchronously — so for a few seconds after the assignment
transaction commits, an invoice against that wallet still fails. getWallet and
createWallet poll that index before returning, which is what makes
createInvoice work immediately afterwards. Pass {waitForIndex: false} if you
want the wallet back sooner and will handle that yourself.
await client.listWallets(); // every wallet assigned to this identity
4. Read balances
const balances = await client.getBalances();
// [{chain: 'niles', token: null, symbol: 'TRX',
// raw: '5000000', amount: '5', decimals: 6},
// {chain: 'sep', token: '4ea636f3…', symbol: 'USDT',
// raw: '12500000', amount: '12.5', decimals: 6}]
raw is the on-chain integer (wei, sun, or the token's own units); amount is
the same value as a human decimal string.
token is the token's L1 contract address, or null for the chain's own
native asset. It is not a ticker — match on this and display symbol, because
two tokens on one network may share a symbol. symbol and decimals come from
the on-ledger token registry; when it does not describe a token, amount and
decimals are null rather than guessed, and raw is still exact.
client.listTokens() returns everything the network credits, if you want the
list before a balance exists.
These are L2 balances — the ledger's own running totals, maintained as deposits are credited and already summed across every wallet that fed them. Not a live RPC call against one wallet's on-chain balance: an L1 transfer the ledger never saw is not reflected here.
5. Send
const {umid, settledOn} = await client.send({
chain: 'niles',
to: 'TSSMHYe...',
amount: '0.001',
});
const receipt = await client.waitForTransfer(umid);
chain says which network's balance to spend; you don't need to own a
wallet to send (an older wallet option is still accepted and only its
chain is used). amount is a human decimal string in the chain's native asset. The conversion
to raw units is exact string arithmetic, not floating point.
Where it settles
to above is an L1 address. Pass a 64-hex Varnir identity instead and the
payment settles on the ledger — instantly, with no fee and no L1 transaction
— rather than going out over the chain:
const {umid, settledOn} = await client.send({
chain: 'niles',
to: '9935e4c1...', // a Varnir identity, not an address
amount: '0.001',
});
settledOn; // 'ledger' — no waitForTransfer needed, nothing to confirm on L1
Settling on the ledger skips the L1 wait, but not the co-sign check: a send
over the signing key's threshold still comes back with .proposal set and no
money moved, whichever path it settles on — see A send can succeed without
moving money.
An address settles on the ledger too, when it names a wallet Varnir has
assigned and can still confirm the owner of; otherwise send falls back to
the L1 withdrawal above. Either way, settledOn on the result — 'ledger'
or 'l1' — says which path was actually taken. See Paying: send() and
where it settles for the
full routing rules, the settlement option, and transferOnLedger.
Two things to know:
- Native and token sends both work, and neither carries a Varnir fee.
A native send pays its own L1 gas in-kind, on top of the amount: the
recipient gets the full amount, and your native balance must cover the
amount plus a gas hold (taken at presign, settled at confirm, with any
unused part released). Pass
token(the token's L1 contract address) to send a token instead — see the token sample in Read balances for how tokens are identified; it goes downVarnir.CryptoTransfer's delegated branch, where Varnir fronts the L1 gas instead of you paying it (to be recovered through credits once they are charged), with decimals resolved from the on-ledger registry. sendreturns before the money moves. What the ledger accepts is a presign request; the real L1 broadcast is finished asynchronously by the gateway's presign queue.getTransfer(umid)returnsnulluntil it completes;waitForTransfer(umid)polls until it does.
Which wallet actually sends
The balances in the previous section are pooled across every wallet you own. The L1 broadcast is not: it is made by one wallet, out of what sits at that wallet's own address. So the ledger can approve a send that no single wallet can actually make — your pooled balance says 100 TRX, but it is spread across five wallets holding 20 each.
You don't choose the signer: the gateway picks which wallet signs, at signing
time, against its own fresh L1 view — the client's own view of L1 balances is
read some time before submitting and would already be stale by the time it
mattered. Which wallet signed is only visible after the fact, from
getTransfer(umid) once the send has settled.
To see the picture yourself before submitting:
// Real on-chain balances, per wallet — not the pooled L2 totals
const holdings = await client.getWalletL1Balances('niles');
When nothing on the chain can cover it, the send still goes out and fails at the gateway rather than being refused up front by the client.
Payout speed
An L1 withdrawal takes an optional speed: 'economy' (out within 24 hours,
the default), 'standard' (within an hour) or 'instant'.
const result = await client.send({chain: 'niles', to: 'TSSMHYe...', amount: '1', speed: 'instant'});
result.speed; // 'instant' ('economy' when omitted; unset on a ledger settlement)
result.speedRecorded; // true / false when you passed a speed; unset when you didn't
Payout speeds are not built on the network yet: every withdrawal still goes
out immediately and is counted at the Standard rate. The speed is not part of
the signed transaction. An explicit speed is recorded afterwards with
POST /api/withdrawals/speed, signed with the identity's own key, and
speedRecorded: false does not mean the withdrawal failed. See How credits
work.
Repeat payments
The ledger refuses an identical payment — same sender, network, asset, raw
amount, recipient and ref — made within 10 minutes of the last one. It
throws VarnirError with code REPEAT_PAYMENT, and .priorUmid / .priorAt
name the earlier payment. That usually means a retry of a payment that
already went through, so check before sending again.
OUTCOME_UNKNOWN is the other "don't resend" error: the transaction
committed (.committedUmid), but what it created could not be read back
in time, so it is not yet known whether it moved money or is held for
co-sign. Confirm with the transaction history or getTransferProposal before
doing anything else.
import {VarnirError} from '@varnir/chain-client';
try {
await client.send({chain: 'niles', to, amount: '5', ref: 'invoice-1042'});
} catch (err) {
if (err instanceof VarnirError && err.code === 'OUTCOME_UNKNOWN') {
console.error('commit unknown:', err.committedUmid);
// err.message names the created stream to check:
// client.getTransferProposal(streamId)
// or look the committed umid up in the transaction history. Do not resend.
} else {
throw err;
}
}
ref(1–256 characters) is your payment reference. Two sends that differ only inrefare different payments, so a recurring payment should use a differentrefeach time ('rent-2026-10'). Any key may set it.repeat: truesends a deliberate duplicate anyway. Only the owner key may set it; any other key getsNOT_OWNER.
See Idempotency and retry for how this fits with retrying after a network failure.
New-recipient hold
The ledger has a protection that holds the first payment an identity
makes to an address or Varnir identity it has never paid, for 24 hours. The
owner is emailed a cancel link and a speed-up code, if they have an email on
file. It is switched off on
the network today (a contract setting that is off by default); when it is
on, a held send still succeeds and comes back with hold set:
const result = await client.send({chain: 'niles', to, amount: '5'});
if (result.hold) {
// Debited, but nothing has reached the recipient yet.
result.hold.id; result.hold.until;
}
The TypeScript SDK has the calls to manage it: getTransferHold(id),
speedUpTransfer(holdId, code) (owner key), cancelHeldTransfer(holdId),
getWithdrawalHoldSetting(), setWithdrawalHold(enabled) (turning it off
needs the owner key and takes 24 hours to apply) and learnKnownAddresses.
waitForTransfer throws WITHDRAWAL_HELD for a held send rather than
polling for a day. A payout service that pays new recipients all the time
would turn it off. The other SDKs do not have these calls yet.
6. Raise an invoice
const invoice = await client.createInvoice({
wallets: [wallet], // a payment into any of them settles it
name: 'Invoice #1',
amount: '10',
currency: 'native', // or 'USDT'
reference: 'your-own-id', // optional, must be unique across your invoices
callbackUrl: 'https://example.com/hooks/varnir', // optional
});
invoice.url; // the hosted payment page
A new wallet for each invoice
Businesses usually expect every invoice to have its own deposit address, so the
payer sends the exact amount and two invoices for the same amount can never be
confused. Pass newWallets instead of wallets: the SDK creates one fresh
wallet per chain you list, with the key type you choose, and raises the invoice
on those wallets only.
const invoice = await client.createInvoice({
newWallets: {chains: ['sep', 'niles'], keyMethod: 'standard'}, // or 'mpc-tss'
name: 'Order 1042',
amount: '100',
currency: 'USDT', // a native-coin invoice takes one chain
});
invoice.url; // the hosted payment page
invoice.wallets; // the new wallets, with the addresses to pay
The new wallets are created before the invoice, belong to your identity for
good, count towards your plan's wallet limit, and cost the single-use invoice
wallet price in credits (How credits work), even if the invoice
itself is then refused. Varnir records them as invoice wallets, so the
explorer's user page can show them apart from your other wallets. Every SDK
has the same option, and the wallet's Invoices page has it as a New wallet
for this invoice checkbox. Over the agent server's HTTP API the same call is
POST /v1/createInvoice with the same body.
You do not manage an API key for this. Varnir's bookkeeping endpoints
authenticate with a signed request, and by default this signs it with your
identity's own key — the same privateKeyHex you already onboarded with.
Nothing is generated, registered or left behind. Varnir recognises the
signature as yours because your identity's id is itself a hash of that key —
the same hash Onboard used to mint it — so there is no lookup-table entry to
make before you can act as yourself.
If you want a truly separate, scoped, independently removable key instead — most commonly because you're about to hand access to a third-party developer or a less-trusted service, and you don't want them to ever hold your onboarding key — see the next section.
7. Add a scoped key
A key that is only allowed to talk to Varnir's REST API is an off-ledger
key: a real authority on your identity's ledger stream, carrying the scopes
you grant it and nothing else. Adding one is a ledger transaction, signed with
your own onboarding key:
const scopedKey = VarnirClient.generateKey(); // falcon-512 by default
await client.addKey({
publicKeyHex: scopedKey.publicKeyHex,
role: 'offledger',
scopes: ['invoices:create'],
// Optional. An absolute instant, zone required. Omit it and the key
// never expires.
expire: '2027-01-01T00:00:00Z',
});
generateKey() returns both halves, and the private half never leaves your
process — Varnir only ever sees the public one, in the transaction that
attaches it.
role: 'offledger' is the hard part of the guarantee, and it is enforced by
the contracts rather than by a server: Varnir.CryptoTransfer and
Varnir.Swap both refuse a signer labelled offledger outright, so a key
added this way can never be the one that moves funds, whatever else it
carries. expire is enforced by the ledger engine — see
Keys for what that
guarantee does and doesn't cover.
Adding a key does not change which key this client signs with. To sign REST calls with the new one, hand both halves to the client:
client.useApiKey({privateKeyHex: scopedKey.privateKeyHex, publicKeyHex: scopedKey.publicKeyHex});
client.useOwnKey(); // and back to your onboarding key
The attached key is scoped, so every later call on this client is scoped too.
A key holding only invoices:create will fail
setWebhookUrl with does not have the 'webhooks:manage' permission, even
though you are still holding the onboarding key that would have been allowed.
useOwnKey() is the way back.
Or build a whole separate client from just the key — no onboarding key involved at all — the way you'd hand access to a third-party developer or a less-trusted service:
const delegatedClient = new VarnirClient({
identity: client.identity,
apiKey: {privateKeyHex, publicKeyHex}, // no privateKeyHex field at all
});
Keep privateKeyHex somewhere of your own if you want either of these. It is
what makes a key durable across sessions; without it you are attaching a
brand-new one every time.
addKey/useApiKey pair a brand-new keypair to your identity; it is never
derived from, and cannot derive, your identity's own onboarding key (and the
reverse holds too — nothing about the identity id lets you derive the scoped
key). Knowing both client.identity and the scoped key's privateKeyHex is
what lets a later, less-privileged process — or a third party you hand the key
to — act as this identity against Varnir's own API (createInvoice,
webhook settings, anything its scopes allow) without ever holding your
original signing key.
It does not go further than that, by design. A client built with apiKey
instead of privateKeyHex (as above) throws immediately from
getWallet/createWallet/send, because those sign real ledger
transactions, and an offledger key is refused by the money contracts even
when it is a genuine authority. That's the whole point of handing someone a
key instead of your onboarding key: remove it
(client.removeKey(scopedKey.publicKeyHex), from a client built with your
own onboarding key) and they're locked out, and they were never able to
touch anything on-chain in the first place. For a process that should only
ever touch Varnir's API and never construct a VarnirClient at all, you can
also sign requests by hand with buildApiKeyAuthHeaders — see
The signing model.
await client.listLedgerKeys(); // every key on this identity, owner key included
await client.removeKey(scopedKey.publicKeyHex); // withdraw it before its `expire` comes round
Keys is the whole model: the other roles a key can hold, the spend policy a signing key can carry, and the full scope vocabulary.
8. Set a default deposit callback
Every deposit sighted for one of your invoices notifies a URL: either that
invoice's own callbackUrl (see step 6), or, if it didn't set one, this
identity's global default.
const settings = await client.setWebhookUrl('https://example.com/hooks/varnir');
settings.callbackUrl; // the URL you just set
settings.secret; // the HMAC-SHA256 secret every delivery is signed with
The secret is generated once — the first time any callback URL, global or
per-invoice, is set for this identity — and then kept indefinitely, so a
receiver's verification code does not have to change every time you call
setWebhookUrl again with a different URL.
await client.getWebhookSettings(); // read it back
await client.deleteWebhookUrl(); // remove the default (per-invoice overrides are unaffected)
On the receiving side, check each delivery before you trust it:
import {verifyWebhookRequest} from '@varnir/chain-client';
// rawBody: the request body exactly as received, before JSON parsing.
if (!verifyWebhookRequest(secret, rawBody, request.headers)) {
return new Response('bad signature', {status: 401});
}
It checks the v1= HMAC and refuses a timestamp more than 5 minutes from
your clock. See Webhooks for the delivery payload's shape
and the scheme itself.
9. Read transaction history
const recent = await client.listTransactions({limit: 50});
// [{id, kind: 'l2' | 'swap' | 'withdrawal' | 'deposit', umid, network,
// token, amount, from, to, status, when}]
This is the identity's recent value movements — ledger transfers, swap fills,
withdrawals and deposits — as a user-facing projection, not the raw event
stream. It defaults to your own identity; the identity filter is public (see
Backends — reading transaction history
for what that means and how each method is served).
Pointing somewhere else
network: 'testnet' resolves to Varnir's public testnet. For a private or local
deployment, override just the parts that differ:
const client = new VarnirClient({
network: 'testnet',
privateKeyHex,
identity,
endpoints: {
ledgerNodes: ['http://localhost:5260'],
gateways: ['http://localhost:8080'],
},
});
Anything omitted falls back to the named network's own value.
The low-level API is still there
VarnirClient is a wrapper over primitives this package still exports, and they
remain the right thing to reach for if you need a transaction shape it does not
build:
import {signPayloadBase64, sendToAnyNode, gatewayGet, buildApiKeyAuthHeaders} from '@varnir/chain-client';
packages/chain-client/src/varnir-client.ts is the worked example of using
them — every $i/$o/$r shape in this flow is in that one file, commented.
Running the real thing
cd packages/chain-client
npx tsx examples/wallet-and-invoice-flow.ts
It prints the generated recovery phrase first — save it if you want to reuse the identity — then runs the whole flow against the live testnet.
Next
- Invoices — every field, and how payment is detected.
- Webhooks — getting told when an invoice is paid.
- The signing model — what is signed, and why.
- Backends — which SDK methods need a chain node, any gateway, or the centralized scanner.