Skip to main content

Getting started

This walks through the whole flow an external integration needs: create an identity, put it on the ledger, get a wallet assigned to it, register an API key, and raise an invoice against that wallet.

The complete, runnable version of this is packages/chain-client/examples/wallet-and-invoice-flow.ts in the monorepo. Every snippet below is a piece of it.

Nothing here sends a private key anywhere

Steps 1–3 are signed in your own process and posted directly to a chain node. Step 5 talks to Varnir's API, but authenticates with a request signature, not a transmitted secret. See The signing model.

Packages

Two workspace packages do the work:

  • @varnir/signing — recovery phrases and secp256k1 key derivation.
  • @varnir/chain-client — transaction signing (signPayloadBase64), API-key request signing (buildApiKeyAuthHeaders), and a typed client for gateway lookups.

@varnir/chain-client has three entry points, because two of them pull in a platform-specific ledger SDK:

// Gateway lookups. No platform-specific SDK - safe to import anywhere.
import {ChainClient} from '@varnir/chain-client';

// Direct-to-node, real Node.js only (node:crypto, fs, bip39).
import {NodeTransport, importEllipticCurveKey} from '@varnir/chain-client/node';

// Direct-to-node, browsers and React Native. Signs via @noble/curves,
// pure JS, no Node core modules at all.
import {WebTransport, importEllipticCurveKey} from '@varnir/chain-client/web';

The split matters: each transport lives behind its own subpath, so the SDK you are not using is never imported at all rather than relying on your bundler to tree-shake it out.

For the flow below you need none of the transports — only the two signing helpers and fetch.

Constants

const SCANNER_BASE_URL = 'https://scanner.varnir.site';
const LEDGER_NODES = [1, 2, 3, 4].map((n) => `https://node${n}-testnet.varnir.site`);
const VARNIR_NAMESPACE = 'varnir';
const ONBOARD_CONTRACT_ID = '79bfad7705d57b81e4e15cbce425686d97e240972e5d319058c675685bc4dc98';
const ASSIGN_OWNER_CONTRACT_ID = 'fd9e832e1169d5fb1aa7a591cdbd8bb74dff7addde4e26176bf21f6569ef8e64';

A real integration reads these from its own config. They are testnet values.

1. Create an identity key

import {generateRecoveryPhrase, derivePrivateKeyFromPhrase} from '@varnir/signing';

const phrase = generateRecoveryPhrase(); // 12 words
const keyPair = derivePrivateKeyFromPhrase(phrase);
// { privateKeyHex, publicKeyHex } - publicKeyHex is the COMPRESSED (33-byte) form

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.

2. Onboard the identity

Onboarding is a self-signed transaction posted straight to a chain node.

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

const tx = {
$selfsign: true,
$sigs: {} as Record<string, string>,
$tx: {
$contract: ONBOARD_CONTRACT_ID,
$namespace: VARNIR_NAMESPACE,
$i: {otk: {publicKey: keyPair.publicKeyHex, type: 'secp256k1'}},
},
};
tx.$sigs.otk = signPayloadBase64(tx.$tx, keyPair.privateKeyHex);

const response = await sendToAnyNode(tx);
const identity = response.$streams?.new?.[0]?.id;

sendToAnyNode is a small helper that POSTs to each of LEDGER_NODES in turn until one answers, and treats a non-empty $summary.errors as a failure:

async function sendToAnyNode(tx: unknown) {
let lastError: unknown;
for (const baseUrl of LEDGER_NODES) {
try {
const res = await fetch(`${baseUrl}/`, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(tx),
});
const body = await res.json();
const error = body.$summary?.errors?.[0];
if (error) throw new Error(error);
return body;
} catch (error) {
lastError = error;
}
}
throw lastError ?? new Error('No ledger node was reachable.');
}

Onboarding is idempotent, and you can predict the identity

A Varnir identity's stream id is deterministic from the public key, so an already-onboarded key's identity can be recovered with no transaction at all:

import {sha256} from '@noble/hashes/sha256';

const ONBOARD_STREAM_NAME = 'notabox.identity';

function computeDeterministicIdentity(publicKeyHex: string): string {
const hash = sha256(new TextEncoder().encode(publicKeyHex + ONBOARD_STREAM_NAME));
return Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
}

Check GET /api/identity/:id/exists first and skip the transaction if it comes back {"exists": true}. And handle the race: a second onboard of the same key fails with Deterministic Stream Name Exists, which means the identity is already there — treat it as success and use the computed id.

3. Claim and assign a wallet

Claiming asks Varnir for an unassigned wallet id. It is an unauthenticated read — it hands out ids, it does not decide who owns one.

const res = await fetch(`${SCANNER_BASE_URL}/api/wallets/claim/niles/trx`);
const wallet = await res.json(); // { id, address }

Ownership is established on-chain, signed with your identity's own key:

const tx = {
$sigs: {} as Record<string, string>,
$tx: {
$contract: ASSIGN_OWNER_CONTRACT_ID,
$namespace: VARNIR_NAMESPACE,
$i: {owner: {$stream: identity, personal: true}},
$o: {wallet: {$stream: wallet.id}},
},
};
tx.$sigs[identity] = signPayloadBase64(tx.$tx, keyPair.privateKeyHex);
await sendToAnyNode(tx);

Then wait for it to show up

Do not invoice immediately after assigning

The invoice endpoint checks wallet ownership against Varnir's own database, which is populated asynchronously by the gateway pushing a wallet-assigned event — it is not a live ledger read. For a few seconds after the assignment transaction commits, creating an invoice against that wallet still fails with Every wallet must belong to this identity.

Poll the same source the invoice route reads — GET /api/users/:id/wallets — rather than sleeping, and rather than polling something faster like the ledger itself, which would not predict when the invoice endpoint starts succeeding.

async function waitForAssignment(walletId: string, identity: string, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${SCANNER_BASE_URL}/api/users/${identity}/wallets`);
if (res.ok) {
const wallets = await res.json();
if (wallets.some((w: {id: string}) => w.id === walletId)) return;
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Wallet ${walletId} is still not assigned after ${timeoutMs}ms.`);
}

4. Register an API key

Bookkeeping endpoints (invoices, and anything else that is a plain database write rather than a ledger transaction) authenticate with a signed request. The API key is its own secp256k1 keypair, deliberately not your identity key, so it can be scoped and revoked independently.

import {secp256k1} from '@noble/curves/secp256k1';

const privateKey = secp256k1.utils.randomPrivateKey();
const publicKey = secp256k1.getPublicKey(privateKey, true); // compressed
const toHex = (b: Uint8Array) => '0x' + Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const apiKeyPair = {privateKeyHex: toHex(privateKey), publicKeyHex: toHex(publicKey)};

await fetch(`${SCANNER_BASE_URL}/api/api-keys`, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({
owner: identity,
label: 'SDK example',
publicKey: apiKeyPair.publicKeyHex, // only the public half is ever sent
permissions: ['invoices:create'],
ttlDays: 1,
}),
});

Full detail in API keys.

5. Raise an invoice

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

const invoice = {walletIds: [wallet.id], name: 'SDK example invoice', amount: '10', currency: 'native'};
const bodyText = JSON.stringify(invoice);

const res = await fetch(`${SCANNER_BASE_URL}/api/invoices`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...buildApiKeyAuthHeaders('POST /api/invoices', bodyText, apiKeyPair.publicKeyHex, apiKeyPair.privateKeyHex),
},
body: bodyText, // the SAME string that was signed - see below
});

const {id, url} = await res.json();
console.log(`Invoice created: ${SCANNER_BASE_URL}${url}`);
Sign and send the identical string

bodyText must be the exact string you POST. Building the body once and reusing that string is not a style preference — re-serialising a parsed object is not guaranteed to round-trip identically (key order), and the signature is over the raw text. Serialise once.

url is the hosted payment page for the invoice, e.g. /pay/<id>.

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 walks all five steps against the live testnet.

Next