Varnir
Scanner Playground Docs

Live on Ethereum Sepolia, Tron Nile & BNB Smart Chain testnets

A payment rail
AI agents can be
trusted with.

Software agents are starting to spend money. Varnir is the ledger underneath them — a chain in its own right, settling across the chains they already use. One agent proposes a payment, other agents verify it, and nothing moves until they have.

Testnet. The ledger, wallets, exchange, invoicing and SDK are built and running on public test networks, with no real funds. Two-key approval is built; the wider multi-verifier layer is where this is going, not something you can use today. This page says which is which.

01 — The problem

An agent that can pay is an agent that can be wrong expensively.

Give a model a key and it can move money. That is the whole security review. Nothing sits between the inference and the irreversible transaction: no second opinion, no policy check the model cannot talk its way past, no record of why the payment was thought correct.

  • i

    One model, one signature, no recourse

    One agent holding one private key is a single point of failure. Prompt injection, a hallucinated invoice, a poisoned tool response — each ends the same way: a valid signature over a payment nobody sanctioned.

  • ii

    Custody is the actual risk

    The usual fix is to hand the key to a platform. Now the platform can move your funds, and its breach is your breach. Swapping one unaccountable signer for another is not a control.

  • iii

    Nothing to audit afterwards

    "The agent decided to" is not an audit trail. Without a record of what was proposed, what checked it and why it passed, nobody can reason about an automated treasury — and nobody accountable will sign off on one.

02 — The approach

Put agents on both sides of the transaction.

An agent proposes a payment. It does not execute one. Independent verifying agents check the proposal against treasury policy, and only an approved proposal settles. The proposer never holds unilateral authority, because authority is the thing being split.

The rail — your policy plugs in Proposing agent "pay 4,200 USDT" Verifier counterparty + limits Verifier policy + budget Verifier anomaly + duplicate Settlement on-ledger, final
Varnir enforces the split — who may propose, who may confirm, and that no key confirms its own proposal. What each verifier checks is your policy to write, not ours to prescribe. Every decision — approval or refusal — is a record you can go back and read.

That is the shape it is heading for. The first rung is built and running on public testnet today: two keys on one identity, and a payment that cannot happen until the second one says so.

Built — on testnet today send() polls its own identity confirmTransfer() Agent · spender key stake 0 · cannot send Proposal, not payment nothing has moved Approver · treasury key stake 80 · never its own Settlement on the stored terms
The split is enforced by key authority at consensus — not by a permission check an API could get wrong, and not by the agent behaving itself. The ledger refuses a compromised agent's payment. Nor can the approver rewrite what it approves: amount, destination and network are read back out of the stored proposal before anything executes. A stake-80 key has dual status — it can raise a transfer as well as confirm one — and the one thing it cannot do is confirm the transfer it raised: the proposal records which key created it, and that key's confirmation is refused. So two agents can check each other with nobody privileged above them, and one compromised key is not enough — even though that key can do both jobs. How it works, in code →
Built

Your key never reaches our servers

Not a policy — a property of the code, everywhere in the stack. Keys are generated in your process and stay there. Transactions are signed locally and posted straight to a chain node, not relayed through an API that could have signed for you.

The one thing our API is asked during wallet setup is which wallet id to claim — a public, unauthenticated read. It hands out ids. It cannot assign one to you; that takes a transaction signed with your own key.

Built

Signed requests, not bearer tokens

An API credential is a secp256k1 keypair you generate; we store only the public half. Each request carries a signature over the route, a timestamp and the exact body — so there is no secret in a header to leak through a log or a proxy, and a signature cannot be replayed against a different endpoint.

Built

Real infrastructure, not a diagram

Four ledger nodes, with onboarding, wallet assignment, transfer and swap written as on-ledger contracts. A public scanner over it. Wallets, an exchange, invoicing and deposit webhooks across two L1 testnets. You can use it now.

Built

Approval as a first-class record

A proposal is not an allow or a deny that vanishes once acted on. It is a durable, on-ledger record of exactly what was proposed and which authority confirmed it, readable on the public scanner afterwards. That is what lets someone accountable sign off on an automated treasury: they can read what it did, and on whose say-so.

Built

No bridge to hack

Varnir runs its own ledger with an L2 balance layer over it — and no cross-chain bridge. Nothing is locked in a contract on one chain so a wrapped copy can be minted on another. USDT stays on Tron, ether stays on Ethereum; Varnir mirrors what arrives and settles it internally.

Bridges are the largest single exploit class in crypto: value pooled behind one contract and one trust assumption. Not having one does not make Varnir unhackable. It does mean the bridge-hack surface — the one that comes with wrapped assets — is not there.

Built

Its own chain — and a layer over others

Varnir runs its own ledger with its own consensus: four nodes, its own state. By the usual test that makes it a layer-1, not a contract deployed on someone else's chain. It just doesn't try to be the chain your assets live on.

Value stays where it was deposited — USDT on Tron, ether on Ethereum. Varnir mirrors the deposit onto its own ledger, settles transfers and trades between identities internally, and withdraws back to the origin chain on the way out. So it is both: a sovereign ledger used as settlement tissue between the chains that already hold the value, with no token or gas economy of its own. Balances stay denominated in whatever you deposited.

03 — Live today

Testnet, and actually running.

Everything below is deployed on public test networks and reachable from this page. None of it handles real funds, and we would rather say so plainly than let a landing page imply otherwise.

Scanner

The ledger and the three L1 testnets it watches, in public: transactions, identities, wallets and deposits.

scanner.varnir.site

Web wallet

Onboard an identity, get a wallet assigned, send and receive — entirely in the browser. The private key is generated there and never leaves.

scanner.varnir.site/wallet

Exchange

Make, take and cancel resting orders that settle atomically on the ledger — both legs in one transaction or neither, instant and gas-free, across assets and across chains. An off-ledger matcher pairs orders; it cannot alter their terms or hold your funds. Sign in the browser or on your paired phone.

scanner.varnir.site/exchange

Invoicing & webhooks

Named, referenced invoices across one or more wallets. Payment is detected from observed deposits; an HMAC-signed callback fires at pending and again at confirmed.

USDT and native, both networks

Token registry

Which tokens the network credits is on-ledger state, not a list compiled into the code. Balances are keyed by the token’s L1 contract address, not its ticker — two tokens can share a symbol and never be confused for each other, and adding one is a ledger transaction, not a software release.

read it with listTokens()

Settlement layer

A custom Activeledger fork running as a four-node network, with onboarding, wallet assignment, transfer and swap implemented as on-ledger contracts.

Ethereum Sepolia, Tron Nile & BNB Smart Chain

SDK

@varnir/chain-client and @varnir/signing — everything the web wallet does, from your own code, signed on your side of the wire.

TypeScript

Playground

Edit and run the SDK against the live testnet, in the browser — onboard an identity, claim a wallet, raise an invoice, and watch each step's real result come back.

playground.varnir.site

Mobile wallet

A native wallet on the same SDK — onboard, send, receive, trade, raise invoices and manage signing keys from the phone. It also signs for the web scanner: pair by QR, and every action that needs a signature is approved on the phone, where the key stays. Works on-device today; not in the app stores yet.

On-device keys · QR remote signing

04 — Quantum-safe by design

The first quantum-safe order book.

The first we know of, anyway. Nobody can say when a computer running Shor's algorithm at scale arrives; when it does, it can derive a private key from any secp256k1 public key it can see, and most of crypto's public keys are already in view. Varnir is in an unusual position: it runs its own ledger, so it gets to choose its own signature scheme — and it settles on chains whose scheme it cannot choose. What follows is what that split lets us do, and where it stops.

Built — on testnet today

Post-quantum identities, on by default

A Varnir identity is a key on our own ledger, so we decide what kind of key it is. A new identity signs with Falcon-512 by default, with ML-DSA-65 selectable — both NIST post-quantum schemes — and its 12-word recovery phrase derives that same post-quantum key. No classical key hides in the backup. A post-quantum key can sit beside a classical one on the same identity while everything around it catches up.

Everything that key authorises on the L2 — transfers, withdrawal authorisation, and every make, take and cancel on the exchange — is signed post-quantum. That is what the order-book claim rests on. It is the identity key and its recovery, not the whole account: the settlement gateway's custody-signing key is still classical secp256k1, and so are identities made before the release. The recovery phrase is Varnir-internal — there is no cross-wallet standard for post-quantum keys yet.

Coming — in design

QER: Quantum Exposure Reduction on L1

Ethereum, Tron and BSC sign with secp256k1, and no layer above them changes that. What Varnir can change is how much value sits behind any key that does get exposed. QER is our own settlement pattern for withdrawals: a fresh wallet for each one, no address used twice, and the change swept to a brand-new wallet in the same act. A key is exposed once, and by then it guards next to nothing.

On Ethereum the signed withdrawal will go out through a private relay rather than the public mempool, closing the window in which a transaction is visible but not yet included — the same route that protects against front-running. That piece is Ethereum-only; on Tron and BSC, QER is the fresh-wallet discipline alone.

Three things this is not. It is not your whole account: the identity key and its recovery are post-quantum; the gateway's custody key is not yet, and neither are identities made before the release. It is not L1 becoming quantum-proof: nothing Varnir does changes what Ethereum accepts as a signature, and QER — still a design, not a code path — lowers the value at exposure rather than removing it. And it is not mainnet: this is the public testnet, with no real funds. Each row gets marked built here when it is, and not before. What is covered, and what isn't →

05 — Where this is going

The convenience of an exchange, without the custody.

What makes a centralised venue pleasant is that a trade is a bookkeeping entry between two accounts on one system — instant, no gas, no waiting for a block. What makes it dangerous is separate: it holds the assets, so it can lose them, freeze them or spend them. The two come apart, and this rail is already on the right side of both.

Built

Balances already settle internally

A deposit credits an L2 balance on your own identity, written on-ledger as it confirms. Reading your balance is reading your own stream — not asking us what we think you have.

Built

And nobody here holds the keys

The keys that could move those funds on L1 are never whole at Varnir — split across independent node operators, or generated so the key is never assembled anywhere at all. There is no operator override to ask for, and none to steal.

Built

Gas-free transfer between identities

The settlement half of trading is written and reachable: one transaction debits an identity's L2 balance and credits another's, touching no L1 chain and paying no gas. It is an SDK call — transferOnLedger — and the swap contract built on the same move settles both legs of a trade in one transaction, or neither. That is what the exchange above runs on. The swap primitive, in code →

Not built

Treasury-grade trading, and fees

A treasury identity can't trade under propose-and-verify yet — an order from one fails closed rather than settling unchecked. And there is no fee in v1: that model is undecided, so nothing is charged.

Not built

Fewer chain transactions, on purpose

A rail paid per transaction wants more of them. Transfers between identities here already touch no chain and pay no gas, so this one gains nothing from volume — and the direction is to act on that: reach a chain only when funds must leave, and then as seldom as possible. That rail can't follow — its own revenue forbids it.

Being Deployed

Lose the key, not the account

Set up once: name people you trust, and a threshold. If the key is ever lost or stolen, collect codes from enough of them, wait out a 48-hour veto window, and the ledger moves the account to a new identity — balances, limits and chosen keys included — freezing the old one so nothing can drain it. Optional, because it needs people you trust. And trusting them isn't trusting Varnir: the gateways deliver codes, never enough to recover an account alone, and a recovery in flight is public.

Agents come first here on purpose. An agent trading against a bounded, approvable balance is a smaller promise than a person's savings, and it exercises the settlement layer for real while the amounts stay proportionate to a testnet. People's own funds are a higher bar, and the bars should be cleared in that order. The full roadmap, including what is missing →

06 — Connect your AI

Give an assistant a wallet, not your keys.

Varnir ships an MCP server you run yourself. Point Claude, ChatGPT or Gemini at it and the assistant gets thirteen tools — read balances, read the order book, place a trade, raise an invoice. What it cannot do is the part that matters.

ai agent · Varnir agent server niles testnet Simulated
What am I holding across the pools, and how much is this key actually allowed to spend?
▸ get_identity {}
→ {
"identity": "a4c1f94d2b8e6f03a17c55e9dd2a4b8c0f3e1d9a2b4c6d8e0f1a2b3c4d567d9",
"network": "niles",
"mode": "writes"
}
▸ get_spend_policy {}
→ {
"identity": "a4c1f94d2b8e6f03a17c55e9dd2a4b8c0f3e1d9a2b4c6d8e0f1a2b3c4d567d9",
"keyFound": true,
"role": "agent",
"policy": {
"version": 1,
"rules": { "TRX-NILE:#native": { "perTxMax": "150000000" } }
},
"note": "Per-tx ceiling 150 TRX on niles native."
}
Reading the ceiling off the ledger before I touch anything: 150 TRX per transaction, and the key is attached as a scoped agent role. Every move below has to fit under that number.
▸ get_balances {}
→ {
"TRX-NILE": [
" { "symbol": "TRX", "token": null, "decimals": 6, "raw": "12480770000", "reserved": "0", "available": "12480770000" },
" { "symbol": "USDT", "token": "9b28202c…bb06c", "decimals": 6, "raw": "0", "reserved": "0", "available": "0" }
]
}
12,480.77 TRX available on niles, none of it reserved by a resting order. No USDT yet - so the whole job is a swap.
I want USDT. Swap about 600 TRX at whatever the book offers, keep slippage under half a percent.
▸ list_tokens {}
→ {
"TRX-NILE": [
" { "symbol": "TRX", "token": null, "decimals": 6, "chargeable": false },
" { "symbol": "USDT", "token": "9b28202c90af764ffa36279857a6d7cd928bb06c", "decimals": 6, "chargeable": true }
]
}
USDT is 9b28…bb06c on niles. It is chargeable - a USDT transfer carries the fee - so the cheap path here is native TRX there and back.
▸ get_order_book {}
→ [
" { "hash": "d9a12c…5b3c", "give": { "chain": "niles", "amount": "5000000000" }, "want": { "chain": "niles", "token": "9b28…bb06c", "amount": "208500000" } },
" { "hash": "3f7a9c…e6d1", "give": { "chain": "niles", "amount": "3000000000" }, "want": { "chain": "niles", "token": "9b28…bb06c", "amount": "125880000" } },
" { "hash": "b84e0a…9c02", "give": { "chain": "niles", "amount": "3500000000" }, "want": { "chain": "niles", "token": "9b28…bb06c", "amount": "147350000" } }
]
Best ask is 5,000 TRX against 208.50 USDT - a rate of about 0.0417 USDT per TRX. I will trade against the book as it stands, with 50 basis points of slippage allowed.
▸ market_order { "give": { "chain": "niles", "amount": "600" }, "wantAsset": { "network": "niles", "token": "0x9b28202c…bb06c" }, "maxSlippageBps": 50 }
→ {
"status": "partial",
"give": { "raw": "548200000", "decimals": 6, "amount": "548.20" },
"want": { "raw": "22859940", "decimals": 6, "amount": "22.86" },
"unfilledGive": { "raw": "51800000", "decimals": 6, "amount": "51.80" }
}
Filled 548.20 TRX for 22.86 USDT at ~0.0417. The book had no more depth, so the last 51.80 TRX came back as spendable - nothing left half in a resting order, nothing wasted.
In parallel, the verifier agent re-reads the same books from a second key. A verifier key cannot raise transfers - the money contracts refuse that outright - so it can check a run and confirm it, never spend on its own. That split is enforced on-ledger, not by convention.
Re-reads the book and balances for the trade as it landed.
Reconciled: 548.20 TRX out, 22.86 USDT in, the last 51.80 TRX back as spendable, nothing reserved.
Countersigns the trade record.
Park 2,000 TRX as a resting sell for USDT at or above the rate we just saw. Leave it until a taker crosses it or I say otherwise.
▸ create_order { "give": { "chain": "niles", "amount": "2000" }, "want": { "chain": "niles", "token": "0x9b28202c…bb06c", "amount": "84.40" } }
→ {
"status": "ok",
"orderHash": "7a41f90e…c3d8",
"order": { "give": { "amount": "2000" }, "want": { "amount": "84.40" } }
}
2,000 TRX are resting at a limit of 84.40 USDT (0.0422/TRX). The balance they cover shows as reserved while it sits - and it sells at that rate or not at all.
Re-read the book - did it cross while we were talking?
▸ get_order_book {}
→ [
" { "hash": "3f7a9c…e6d1", "give": { "chain": "niles", "amount": "3000000000" }, "want": { "chain": "niles", "token": "9b28…bb06c", "amount": "125880000" } },
" { "hash": "b84e0a…9c02", "give": { "chain": "niles", "amount": "3500000000" }, "want": { "chain": "niles", "token": "9b28…bb06c", "amount": "147350000" } }
]
Our hash is gone from the book - a taker crossed it at the limit. 2,000 TRX out, 84.40 USDT in. That is the resting order doing the job it was placed for.
The weekly pay run: send 200 TRX to the ops identity over on niles.
Before signing: 200 TRX is over the key's 150 ceiling, and the contract checks that before the identity's approval gate even comes into play. Let it refuse us cleanly.
▸ transfer_on_ledger { "to": "eb10c71f…399", "chain": "niles", "amount": "200" }
→ {
"status": "error",
"error": {
"code": "POLICY_REFUSED",
"message": "200 TRX exceeds this key's per-transaction ceiling of 150 TRX. The contract refused the transfer; nothing was attempted.",
"retryable": false,
"policy": true
}
}
Refused by the contract, before anything moved - not by me, and not because the request looked wrong. A number the ledger owns is what stopped it. That is the whole point.
Fair - make it 120 TRX. Under the ceiling, and anything over 100 waits on the verifier. Send it.
▸ transfer_on_ledger { "to": "eb10c71f…399", "chain": "niles", "amount": "120" }
→ {
"status": "awaiting_cosign",
"umid": "c8417a5b…aa11",
"message": "The transfer was recorded but NO MONEY HAS MOVED. 120 TRX crosses the identity's co-sign threshold of 100 TRX, so it is held until a second key confirms it. Report this as 'not sent, approval pending'."
}
Recorded as a proposal - and I want to be plain about what that means: your funds have NOT moved. 120 TRX clears the ceiling but crosses the 100 TRX co-sign threshold, so nothing settles until your second key confirms. There is no tool on this server that could approve it myself - by design. I am reporting this as awaiting approval, not as sent.
Reads the proposal c8417a5b…aa11 against the run: 120 TRX, recipient on the allowlist, inside the ceiling.
Confirms it - a verifier key can affirm a proposal it did not raise, and only a confirming key releases it. The chain enforces that, not good manners.
The release is not mine to make - my key was never able to, and no spend policy could change that. The second key lives with the verifier, and I can only watch the settlement land:
▸ get_transfer { "umid": "c8417a5b…aa11" }
→ {
"status": "settled",
"txid": "9f2e6a1c4d8b5f07a3c9e1d2b4e6f180c7a9d3e5b1f2c4a6d8e0b7d9f1a3c5e7",
"confirmed": { "success": true }
}
Settled. 120 TRX moved the moment the verifier confirmed - the agent ran the whole flow, the verifier cleared the one move that crossed the line, and no single agent held both halves.
Good. The two of you stay on the books and I stay out of it - that is the loop.
Run AI One scripted session - balances, a trade, a spending cap, a held transfer.
Built

The key never reaches us, or the model

The server runs on your host, with your key in your environment. Varnir never holds it, and neither does the assistant — it calls a tool, your process signs. The model sees the result, never the secret.

Built

The limit is on the ledger, not in the server

You mint a scoped key with a spend policy attached — a per-transaction ceiling, a period cap, an allowlist — and the contracts enforce it. A compromised server, a jailbroken prompt or a patched binary does not widen it, because nothing in the process is what is holding the line.

Built

It refuses to start with the wrong key

At boot it reads its own key back off the ledger and checks what it really is. Your identity's owner key, a key with no policy, a read-only key: it declines to write rather than run in a shape where the policy argument does not hold.

Built

Over the threshold, nothing moves

A payment above the key's co-sign threshold becomes a held proposal instead of a transfer. The assistant gets a transaction id and no money has moved — and because "it didn't error" is the easiest thing to misread, the tools say so in those words.

Built

It cannot approve its own spending

There is deliberately no tool to confirm or void a held proposal, and none to add a key or move a threshold. A treasury key that did not raise a proposal can still release one — at its amount, past the caps that bound the assistant. So that door is not in the building.

Off by default

A REST API, if you want one

The same operations are reachable over plain HTTP for things that do not speak MCP. It ships disabled, because an open port that can spend is a different risk from a local tool your assistant calls. Set it up →

07 — Build on it

Five steps from nothing to a live invoice.

Generate a key, onboard it, claim a wallet, register a scoped signing key, raise an invoice. Every signature happens in your process.

wallet-and-invoice-flow.ts
// 1. A key you generate, and keep. It never leaves this process.
const phrase = generateRecoveryPhrase();
const { privateKeyHex } = derivePrivateKeyFromPhrase(phrase);

// 2. Onboard it. Signed here, posted straight to a chain node.
const client = await VarnirClient.onboard({ privateKeyHex });

// 3. Claim a wallet, then prove ownership on-chain.
const wallet = await client.getWallet('niles');

// 4. Attach a scoped key — a real authority on your ledger identity.
const scoped = VarnirClient.generateKey();
await client.addKey({
  publicKeyHex: scoped.publicKeyHex,
  role: 'offledger',
  scopes: ['invoices:create'],
});

// 5. Raise an invoice — signed request, no bearer token.
const invoice = await client.createInvoice({
  wallets: [wallet.id],
  amount: '10',
  currency: 'native',
  name: 'Order 4021',
});
# 1. A key you generate, and keep. It never leaves this process.
key = generate_key()              # falcon-512, the onboarding default

# 2. Onboard it. Signed here, posted straight to a chain node.
client = VarnirClient.onboard(key.private_key)

# 3. Claim a wallet, then prove ownership on-chain.
wallet = client.get_wallet('niles')

# 4. Attach a scoped key — a real authority on your ledger identity.
scoped = generate_key(KeyType.SECP256K1)
client.add_key(AddKeyOptions(
    public_key=scoped.public_key,
    role=KeyRole.OFFLEDGER,
    scopes=['invoices:create'],
))

# 5. Raise an invoice — signed request, no bearer token.
invoice = client.create_invoice(
    wallet_ids=[wallet.id],
    amount='10',
    currency='native',
    name='Order 4021',
)
// 1. A key you generate, and keep. It never leaves this process.
var key = Signing.GenerateKey();   // falcon-512, the onboarding default

// 2. Onboard it. Signed here, posted straight to a chain node.
using var client = VarnirClient.ForKeyPair(key);
await client.OnboardAsync();

// 3. Claim a wallet, then prove ownership on-chain.
var wallet = await client.GetWalletAsync("niles");

// 4. Attach a scoped key — a real authority on your ledger identity.
var scoped = Signing.GenerateKey(Keys.Secp256k1);
await client.AddKeyAsync(new AddKeyOptions {
    PublicKey = scoped.PublicKey,
    Role      = KeyRole.OffLedger,
    Scopes    = new[] { "invoices:create" },
});

// 5. Raise an invoice — signed request, no bearer token.
var invoice = await client.CreateInvoiceAsync(
    walletIds: new[] { wallet.Id },
    amount: "10",
    currency: "native",
    name: "Order 4021");
// 1. A key you generate, and keep. It never leaves this process.
val key = Signing.generateKey()   // falcon-512, the onboarding default

// 2. Onboard it. Signed here, posted straight to a chain node.
val client = VarnirClient.forKeyPair(key)
client.onboard()

// 3. Claim a wallet, then prove ownership on-chain.
val wallet = client.getWallet("niles")

// 4. Attach a scoped key — a real authority on your ledger identity.
val scoped = Signing.generateKey(Keys.SECP256K1)
client.addKey(AddKeyOptions(
    publicKey = scoped.publicKey,
    role      = KeyRole.OFFLEDGER,
    scopes    = listOf("invoices:create"),
))

// 5. Raise an invoice — signed request, no bearer token.
val invoice = client.createInvoice(
    walletIds = listOf(wallet.id),
    amount    = "10",
    currency  = "native",
    name      = "Order 4021",
)
// 1. A key you generate, and keep. No phrase — nothing upstream mints one,
//    so hang onto the private string. Falcon-512 isn't signable by the Go
//    SDK, so the default here is ml-dsa-65, the post-quantum type it is.
key, _ := varnir.GenerateKey("") // "ml-dsa-65" (default) or "secp256k1"

// 2. Onboard it — the identity is a pure function of the key, computed
//    here and posted straight to a chain node.
client, _ := varnir.Onboard(ctx, varnir.Options{
    PrivateKey:       key.PrivateKey,
    Nodes:            []string{gateway},
    ApiBaseUrl:       apiBase,
    TransferContract: transferContract,
    KeysContract:     keysContract,
    OnboardContract:  onboardContract,
})

// 3. Claim a wallet, then prove ownership on-chain.
wallet, _ := client.CreateWallet(ctx, "niles", "standard")

// 4. Attach a scoped key — a real authority on your ledger identity.
scoped, _ := varnir.GenerateKey(varnir.Secp256k1)
_, _ = client.AddKey(ctx, scoped.PublicKey, varnir.Secp256k1)

// 5. Raise an invoice — signed request, no bearer token.
invoice, _ := client.CreateInvoice(ctx, varnir.CreateInvoiceOptions{
    Wallets: []string{wallet.ID},
    Name:    "Order 4021",
    Amount:  "10",
})
// 1. A key you generate, and keep. No phrase — nothing upstream mints one.
//    Falcon-512 isn't signable by PHP's pqcrypto_compat, so the default is
//    ml-dsa-65, the post-quantum type PHP can use.
$key = Keys::generateKey(); // ml-dsa-65 (default) or Keys::SECP256K1

// 2. Onboard it — the identity is a pure function of the key, computed
//    here and posted straight to a chain node.
$client = Client::onboard(
    privateKey:       $key->privateKey,
    nodes:            [$gateway],
    onboardContract:  $onboardContract,
    apiBaseUrl:       $apiBase,
    transferContract: $transferContract,
    keysContract:     $keysContract,
);

// 3. Claim a wallet, then prove ownership on-chain.
$wallet = $client->createWallet('niles');

// 4. Attach a scoped key — a real authority on your ledger identity.
$scoped = Keys::generateKey(Keys::SECP256K1);
$client->addKey($scoped->publicKey, 'secp256k1');

// 5. Raise an invoice — signed request, no bearer token.
$invoice = $client->createInvoice(
    [$wallet->id],
    'Order 4021',
    '10',
);
// 1. A key you generate, and keep. No phrase — nothing upstream mints one.
//    Falcon-512 isn't signable by the Rust SDK, so None defaults to
//    ml-dsa-65, the post-quantum type Rust can use.
let key = generate_key(None)?; // None = ml-dsa-65; Some(SECP256K1) for classical

// 2. Onboard it — the identity is a pure function of the key, computed
//    here and posted straight to a chain node.
let mut options = ClientOptions::new("", key.private_key);
options.gateways = vec![gateway];
options.api_base_url = Some(api_base);
options.transfer_contract = Some(transfer_contract);
options.keys_contract = Some(keys_contract);
options.onboard_contract = Some(onboard_contract);
let client = Client::onboard(options)?;

// 3. Claim a wallet, then prove ownership on-chain.
let wallet = client.create_wallet("niles", "standard")?;

// 4. Attach a scoped key — a real authority on your ledger identity.
let scoped = generate_key(Some(SECP256K1))?;
client.add_key(&scoped.public_key, "secp256k1")?;

// 5. Raise an invoice — signed request, no bearer token.
let invoice = client.create_invoice(&NewInvoice {
    wallet_ids: vec![wallet.id],
    name: "Order 4021".into(),
    amount: "10".into(),
    ..Default::default()
})?;

We are early, and we would rather say so.

Varnir is a working testnet with a clear thesis, not a finished product. If AI agents paying on their own behalf is a problem you are thinking about — something to build on, integrate, or argue with — the code is the honest version of the pitch.