Skip to main content

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.

Nothing here sends a private key anywhere

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'.

It waits for the wallet to be usable

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 down Varnir.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.
  • send returns 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) returns null until 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 in ref are different payments, so a recurring payment should use a different ref each time ('rent-2026-10'). Any key may set it.
  • repeat: true sends a deliberate duplicate anyway. Only the owner key may set it; any other key gets NOT_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.

A scoped key is a second, separate keypair — not a substitute identity key

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.