Skip to main content

SDK overview and backends

Varnir ships one reference client, @varnir/chain-client (TypeScript), and eight ports. All nine implement the SDK core contract, so an amount, a network code, a transaction's bytes and an error code mean the same thing in every language. This page holds the model and rules shared by every SDK; each language page covers only install, usage and what is specific to that language.

The SDKs​

LanguagePackageInstallCoverageFalcon-512
TypeScript@varnir/chain-clientnpmReference, 45 public methodsYes (default)
Pythonvarnirpip install git+… (upstream from PyPI)Full parity, 47 methods, 146 testsYes, with the [pq] extra
Kotlin / Javavarnir-sdkBuild with Gradle (upstream from Maven Central)Full parity, 71 testsYes (default)
C# / .NETVarnir.SdkBuild from source (upstream from nuget.org)Full parity, 51 methods, 114 testsYes (default)
Gosite.varnir/sdkgo get site.varnir/sdkFull surface; AddKey sets no role/scopes/policy, no derived balancesNo — signs ml-dsa-65, secp256k1
PHPvarnir/sdkcomposer require varnir/sdkFull surface except polling helpers and derived balancesNo — signs ml-dsa-65, secp256k1
Rustvarnir-sdkCargo, upstream from crates.ioFull surface; add_key sets no role/scopes/policy, no derived balancesNo — signs ml-dsa-65, secp256k1
Dart / FluttervarnirGit dependency (upstream activeledger from pub.dev)Full surface; addKey sets no role/scopes/policy, no derived balancesWith the activeledger_falcon add-on; defaults to ml-dsa-65
SwiftVarnirSwift Package Manager, from a checkout (upstream SDK-Swift)Full surface; addKey sets no role/scopes/policy, no derived balancesYes (default)

Cryptography comes from Activeledger​

No Varnir SDK implements its own signing. Canonical JSON, transaction building and all signing (post-quantum and classical secp256k1) come from Activeledger's SDK for that language, exactly as @varnir/chain-client depends on @activeledger/sdk-node. Key generation and phrase recovery are upstream's too.

The reason: signatures are over the exact bytes of a serialised transaction (§1). A second implementation of byte-exact signing is a second thing that can disagree, and the ledger reports that disagreement as 1220 Signature Incorrect, which says nothing about serialisation and points you at your key instead.

What each SDK does own is the Varnir half: amounts, networks, the error taxonomy, the identity hash, the transaction shapes, and the client over them.

  • The identity hash is Varnir's. sha256(publicKey + "identity") is a Varnir contract, not an Activeledger default; upstream does not fix a public-key-to-identity map, because that would restrict its multi-key support. Because the id is derived from the key, computing it needs no network call and works before the identity exists on the ledger.
  • Public-key derivation from a private key is Varnir's own code in the .NET, Kotlin and Python SDKs, because their upstream SDKs do not offer it. It carries none of the byte-exactness hazards of signing. Go, PHP, Rust and Dart delegate this too.
  • Canonical JSON in the Go, PHP, Rust, Dart and Swift SDKs is upstream's, and was verified byte-identical against the shared golden vectors.

Rules every SDK follows​

Paying: send() and where it settles​

send is the one way to pay. It picks where the payment settles itself, rather than asking the caller to use a different method for each kind of recipient:

  • A 64-hex Varnir identity, or the address of a wallet Varnir has assigned, settles on the ledger — instantly, with no fee and no L1 transaction.
  • Anything else settles on L1 — an ordinary withdrawal, broadcast to the chain and paying that chain's own gas. Nothing about L1 fees changed.

Which path is taken is controlled by send's settlement option, default 'auto':

  • 'auto' — settle on the ledger when the recipient resolves to one; otherwise fall back to L1. A lookup outage, or a wallet whose owner has since been recovered to a different identity, also falls back to L1 rather than blocking the payment.

    One edge is accepted on purpose. If one gateway answers the lookup with "not found" and the next one is down, the SDK counts the lookup as an outage and settles on L1. The money still reaches the right identity, because the deposit is swept to the wallet's owner. What it costs is L1 gas. Use 'ledger' if a payment must never touch L1.

  • 'l1' — always withdraw over the chain, exactly as send behaved before this option existed. The ledger lookup is never attempted.

  • 'ledger' — only ever settle on the ledger. Refuses the send (nothing submitted) rather than falling back to L1, including on a lookup failure.

Every result carries settledOn, either 'ledger' or 'l1', so a caller can tell which path was actually taken (SettledOn in .NET and Go, settled_on in Python and Rust, settledOn in Dart and Swift).

transferOnLedger stays as its own public method: the explicit, ledger-only call for when the caller already knows the recipient is on the ledger and wants no L1 fallback at all.

Before crediting an address-routed ledger payment, the ledger checks the recipient wallet's current on-ledger owner against the one that was looked up. A stale or wrong lookup fails the send rather than crediting the wrong identity.

A send can succeed without moving money​

Over the signing key's co-sign threshold, the ledger records a proposal instead of executing. No error is raised, and a umid comes back either way. That is why send returns a SendResult (with settled / held / proposal) rather than a bare umid: treating "no error" as "money moved" reports a payment that has not happened. See §6.1.

Repeat payments are refused, on purpose​

The ledger refuses a payment identical to one the same identity made in the last 10 minutes — same network, asset, raw amount, recipient and ref — with REPEAT_PAYMENT, naming the earlier payment. Every SDK's send takes a ref (1–256 characters) to tell genuinely separate payments apart, and a repeat flag that only the owner key may set to send a deliberate duplicate. See §7.

Payout speed and the new-recipient hold​

Every SDK's send takes an optional payout speed (economy, the default; standard; instant). It is recorded, not yet acted on — see §8.1.

The new-recipient hold (the first payment to a new recipient held for 24 hours) is in the contract but switched off on the network. Only the TypeScript SDK reports a held send (hold on the result) and has the calls to manage one; see Getting started.

Success is commit, not the absence of errors​

A committed transaction can carry an error: a node lagging one revision votes no while the other three reach consensus, so the response holds both. Every SDK's transport reads $summary.commit > 0. A caller who reads $summary.errors instead concludes nothing happened, retries, and sends the payment twice; this has been observed on a live four-node network, with the two sends 0.7s apart. You only need this if you bypass the client and submit transactions yourself.

Amounts are strings​

Every SDK takes amounts as decimal strings and does string arithmetic. There is no float, double or decimal overload: a binary float cannot hold 18 decimal places, and at wei scale the rounding error is whole tokens. Excess precision is an error rather than truncation, because truncating moves a different amount than the caller wrote. An amount that rounds to zero is also an error. See §4.

Field order is the contract​

The order in which keys are inserted into a transaction object is the order that gets signed. Each SDK builds transactions with an insertion-ordered object (upstream's JsonObject / Object, or a LinkedHashMap), never an unordered or sorted map. Three rules a port gets wrong by being reasonable:

  • $expire is appended last. In the reference it is assigned onto an already-built object, which in JavaScript appends.
  • onboard and key-change omit $entry entirely: absent, not null or empty.
  • An unsupplied optional field is absent, never present-and-null or present-and-empty. {"to": null} and an omitted to are different bytes, so two peers disagreeing about which to send disagree about what the same payment is.

Serialise once: the exact bytes you sign are the bytes you send. Re-serialising is not guaranteed to reproduce them.

$sigs is keyed by stream id​

Never by the $i object key; keying it "owner" fails with Input Signature Incorrect. Onboard is the one exception, using the literal "otk" because no identity exists yet to key by. See §2.8.

Low-S signatures​

The reference signs with @noble/curves, which always returns the canonical lower s (BIP-62). BouncyCastle, python-ecdsa and Go's crypto/ecdsa return whichever s the maths produced. About half of all signatures match without normalisation, so an implementation that omits it works most of the time and then emits valid but non-canonical signatures that a verifier enforcing low-S rejects. §2.2 names SHA-256, ECDSA, RFC 6979, DER and base64 and does not mention s normalisation, because the reference inherits it from a library default. Upstream signers now handle this; it matters only if you sign outside the SDK.

Two base URLs​

A client is configured with two kinds of endpoint:

  • Gateways (nodes / Nodes / gateways, depending on the SDK): the minigate gateways, walked in order until one answers.
  • The API base URL (apiBaseUrl / ApiBaseUrl / api_base_url / apiBaseURL): scanner-web's single origin, where the wallet, invoice and webhook routes live. Reads there are unsigned (they stay CORS-simple on purpose); writes are signed with X-Varnir-* headers over a fixed route tag rather than a $sigs envelope. A client with no API base URL refuses those methods by name rather than sending them to a gateway.

Contract ids and the token registry stream are per-deployment and have no defaults. A method that needs one it was not given fails naming the option to set, before sending anything.

Where each method runs​

The SDKs never invent a hostname. The reference transport is packages/chain-client/src/minigate-transport.ts and packages/chain-client/src/varnir-client.ts.

  • Chain node. The method signs a transaction and submits it to a ledger node, walking the node list until one answers. No gateway or scanner is involved.
  • Any gateway. The method calls a minigate gateway, walking the gateway list. Most reads, and the gateway-mediated writes.
  • Centralized scanner. A signed call to the scanner's API origin, with no gateway fallback.

Chain node​

  • onboard: a signed Varnir.Onboard transaction.
  • addKey / removeKey / approvalKeys: key lifecycle transactions.
  • send: a signed transfer of native value or a token.
  • consolidate / consolidation: a signed consolidation transaction, valid only on a wallet the identity owns.
  • transferOnLedger / transfer: an L2 transfer between two identities on the same ledger.
  • submitSwap / fillOrder / matchOrders: signed swap transactions.
  • cancelOrder / voidOrders.
  • confirmTransfer / voidTransfer: signed transfer-policy confirmations.

Any gateway​

  • identityExists / identityStream: the gateway reads its own identity property or the ledger index it maintains.
  • listTokens / getToken.
  • listWallets: this identity's own wallets, from the durable wallet index a gateway serves at /users/{identity}/wallets, kept current by the scanner's push.
  • getBalances: the gateway's view of an identity's L2 balances.
  • getWallet / claimWallet, and the wallet's L1 deposit status: the gateway hands out a pre-generated wallet and reports L1 balances and deposit status; the chain node finalises ownership via a signed AssignOwner.
  • createWallet: the gateway assigns a pre-generated wallet and onboards it; the node signs the ownership claim.
  • getDeposit.
  • Receipt helpers: getTransfer / waitForTransfer, getTransferProposal.
  • marketOrder: reads the book from a gateway.
  • listTransactions: see below.

Centralized scanner​

Signed X-Varnir-* calls to the scanner's API base URL, with no gateway fallback:

  • createInvoice / getInvoice / listInvoices
  • setWebhookUrl / getWebhookSettings / deleteWebhookUrl

Invoices and webhooks are the only methods that require the scanner origin.

Transaction history​

listTransactions does not require the centralized scanner. Any gateway answers /transactions by querying the scanner's D1 activity feed first and, when the scanner is unreachable, from its own local SQLite history cache. The cache is a rolling window of about 7 days, so history keeps working without the scanner but goes stale beyond that window. The scanner exists as the durable, complete, cross-gateway archive: an identity's complete activity exists only in its D1 feed, which every gateway pushes to.

History is a user-facing projection of value movement, not the raw ledger event feed:

  • Every row has a kind: l2 (ledger transfer), swap (fill), deposit (an L1 deposit) or withdrawal.
  • A deposit row's status is pending until it is credited, then confirmed. On every chain Varnir watches, a deposit can appear as pending before it is credited: on Bitcoin while it is still in the mempool, on the EVM chains and Tron as soon as it is in a block shallower than the network's deposit depth. If its transaction leaves the chain without being credited (replaced by a higher fee, dropped, reorganised away, or reverted), the row reads failed. That can still change: a transaction that later confirms turns the same row confirmed. Only confirmed means the balance moved.
  • listTransactions(identity?, from, limit): identity is a public, unsigned filter that defaults to the SDK's own identity, so a caller can read any identity's history.
  • Lifecycle events (key generation, onboarding, policy changes) are excluded: they are ledger events, not value movements.

Recovery phrases​

Varnir carries no phrase derivation of its own; every SDK uses upstream's. The phrase becomes a BIP-39 seed (PBKDF2-HMAC-SHA512), and the second step depends on the key type:

Key typeSecond step
falcon-512, ml-dsa-65HKDF-SHA512(bip39Seed, info = "activeledger-seed-v1:<type>")
secp256k1HMAC-SHA512("Bitcoin seed", bip39Seed)[0..32], the ordinary BIP-32 master scalar

These are two constructions, not one. secp256k1 phrases predate the post-quantum types, so upstream kept their existing derivation; activeledger-seed-v1 is not involved on the classical path. What is unified is the result: every SDK derives the same key from the same phrase. The classical path is tested against the key Activeledger's Go SDK derives, not against another Varnir port.

The BIP-39 checksum is verified, not just the wordlist. An unchecked mistyped phrase derives a valid key for an identity nobody owns, and the only symptom is the ledger not recognising it.

The Swift SDK can also generate a phrase (Keys.generateRecoveryPhrase(), a standard 12-word BIP-39 phrase, from Activeledger's Swift SDK). The other ports only recover from one, because their upstream SDKs mint none: keep the private key.

Identities from before the switch

Varnir previously used two derivations of its own: HKDF-SHA256 with info varnir/pq-keygen/v1:<type> for post-quantum keys, and the BIP-44 child at m/44'/1'/0'/0/0 for secp256k1. The same twelve words give a different key under each, and nothing throws: you would hold a working key for an identity nobody owns.

Both are deleted and no SDK keeps a legacy reader. A words-only backup issued before the switch opens a different identity now. Anyone holding a private key is unaffected.

Two expiry rules​

  • A ledger key with no expire never expires (fails open). Almost every authority on every network predates the field, so failing closed would report every identity as locked out.
  • A transfer proposal with no expiresAt is treated as expired (fails closed). Confirming a proposal skips the spending-policy check, so an undated proposal would be an unbounded pre-approved payment that ignores the key's current limits.

Both are correct. Do not reconcile them: copying either rule onto the other disables a security control, and the result still looks like working code.

Golden vectors​

Every SDK is tested against one set of golden vectors, generated from the TypeScript client, not written by hand. core.json lives in packages/sdk-python/tests/vectors/ and is copied into each other port at build time (./copy-vectors.sh in Go, PHP, Rust, Dart and Swift) rather than duplicated, so there is one copy to drift from. The copy fails loudly if the source is missing, because a suite that silently checks zero cases is worse than none.

pnpm --filter @varnir/chain-client build
cd packages/chain-client
npx tsx ../sdk-python/tests/vectors/generate.ts > ../sdk-python/tests/vectors/core.json

Generating from the reference pins every port to the bytes that actually ship, rather than to a second reading of the specification. Two ports written from the same spec can agree with each other and both be wrong, or silently disagree (for example, whether ".5" is a valid amount). The vectors are also what allowed signing to move to upstream SDKs: they gate each upstream release.

Divergences the vectors have caught in ports, none of which failed loudly:

AreaWhat differed
Amountsnegatives, excess precision, rounds-to-zero, non-numeric passthrough; rejecting the leading-dot form .5 (the reference pins it to 500000 at 6 decimals)
Transporttreating $summary.errors as failure, which causes a double send
Identityhashing the bare key instead of key + "identity", which yields a valid-looking id for an identity that does not exist
Signingmissing low-S normalisation; about half of all signatures differ from the reference
Errorskey-check ordering producing a confusing message
Serialisationencoding an upstream ordered object with the language's default JSON encoder, submitting every transaction as {}

When writing tests for a port, assert on the request you sent, not only on the response that came back. A node accepts a well-formed empty object and does nothing with it.