Skip to main content

The Varnir SDK for Dart and Flutter

varnir is the Dart port of @varnir/chain-client, for Flutter apps and Dart servers alike. It is pure Dart with no dart:io in the library, and every request goes through a package:http Client you can supply. Shared rules (amounts, field order, $sigs, send results, recovery phrases, golden vectors) are on SDK overview and backends.

Falcon-512 needs an add-on in Dart

Activeledger's Dart SDK signs ML-DSA-65 and secp256k1 in pure Dart. Falcon-512 needs the separate activeledger_falcon package, which loads liboqs (prebuilt for Android, iOS, macOS, Linux and Windows). Enable it once at startup:

import 'package:activeledger_falcon/activeledger_falcon.dart';

void main() {
ActiveledgerFalcon.enable();
// Falcon keys now work everywhere in this SDK.
}

Without it, a Falcon key is refused when the client is constructed, not at the first signature, and generateKey and deriveKeyFromPhrase refuse Falcon with a message naming the add-on.

That is also why generateKey() defaults to ml-dsa-65 here, where the TypeScript and Kotlin SDKs default to Falcon-512: a default that needs a native library would fail on a plain dart run. Falcon is Varnir's client-side default, not something the ledger requires; the onboard transaction names its own key type in $i.otk.type.

Install​

The package is not on pub.dev yet (publish_to: none). Depend on it by git:

dependencies:
varnir:
git:
url: https://github.com/Varnir/varnir.git
path: packages/sdk-dart

Dart 3.10 or later. The floor is upstream's: package:activeledger needs 3.10.

Cryptography​

Signing, key generation, BIP-39 phrase recovery and canonical JSON all come from Activeledger's Dart SDK, activeledger, pinned exactly to 1.0.0 so the golden vectors are re-checked on every upstream bump. This SDK chooses the encoding and the default, not the algorithm. See Cryptography comes from Activeledger.

The identity hash's SHA-256 and the webhook HMAC come from the Dart team's package:crypto.

Use it​

import 'package:varnir/varnir.dart';

final client = VarnirClient(VarnirClientOptions(
identity: identity,
privateKey: privateKey,
gateways: ['https://gate1-testnet.varnir.site'],
apiBaseUrl: 'https://scanner.varnir.site',
transferContract: cryptoTransferContract,
tokenRegistryStream: tokenRegistryStream,
));

// Everything after the amount is a named argument: token (null is the
// chain's own coin), settlement (Settlement.auto), ref, repeat, and the
// payout speed (null is PayoutSpeed.economy).
final result = await client.send(chain: 'niles', to: 'TSC9KBw...', amount: '1.5');

if (result.settled) {
print('sent: ${result.umid}');
} else if (result.held) {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
print('held for a co-signature: ${result.proposalId}');
}

send returns a SendResult; a held send is not an error (why). result.settledOn says which path it took: 'ledger' or 'l1'.

Starting from nothing​

import 'package:varnir/varnir.dart';

// ml-dsa-65 by default, and deliberately so - see the note at the top.
final key = generateKey();

// No identity: onboard computes it from the key - sha256(publicKey +
// "identity"), the same name Varnir.Onboard gives the stream - and refuses
// one if you pass it. Idempotent: a key already on the ledger submits
// nothing at all.
final client = await VarnirClient.onboard(VarnirClientOptions(
privateKey: key.privateKey,
gateways: ['https://gate1-testnet.varnir.site'],
onboardContract: onboardContractId,
));

Keep key.privateKey; nothing can hand it back. deriveKeyFromPhrase(phrase) recovers an ml-dsa-65 key from twelve words, and deriveKeyFromPhrase(phrase, type: VarnirKeyType.secp256k1) a classical one, both through upstream's derivation. The checksum is verified, so a mistyped phrase is refused instead of opening an identity nobody owns. See Recovery phrases for identities created before the switch.

Contract ids and the token registry stream have no defaults; see Two base URLs.

Every network call returns a Future. Pass httpClient: in the options to route requests through your own package:http Client: a proxy, a retry policy, or MockClient in tests.

Or sign without a client​

The offline half is exported on its own, for a service that builds and signs here and submits through something of its own:

import 'package:varnir/varnir.dart';

final chain = chainConfig('niles')!;
final raw = toRawAmount('1.5', chain.nativeDecimals); // "1500000"

final tx = sendTx(
identity: identity,
network: chain.ledgerCode,
token: '#native',
amount: raw,
to: 'TSC9KBw...',
contract: VarnirClient.contractRef(transferContract), // appends "@1"
namespace: 'varnir',
expire: expireMs,
);

// signPayload runs the map through upstream's canonical JSON and signs those
// bytes. The envelope is upstream's Transaction (re-exported here), and
// Transport.submit serialises it with the same function, so what was signed
// is what is sent. $sigs is keyed by the identity stream, not by "owner".
final signature = signPayload(tx, privateKey);
final signed = Transaction(tx: tx, sigs: {identity: signature});
final submitted = await Transport(['https://gate1-testnet.varnir.site']).submit(signed);
print(submitted.umid);

What is covered​

AmountstoRawAmount / fromRawAmount, string arithmetic only
Networksaliases, ledger codes, native decimals, gas reserve
Keysgeneration, phrase recovery, type detection, on-wire form, the Varnir identity hash
Errorsthe full stable code taxonomy, with classification
Transactionsevery $tx shape, byte-identical to the reference
Signingdelegated, dispatching on the key's own tag
Transportthe gateway list and the scanner API, over an injectable http.Client
Onboardinggenerate a key, mint the identity, idempotently
Moneystreams, tokens, send, transfer, consolidate, deposits
Keys (ledger)add, remove, approval thresholds (flat or per asset); role/scopes/policy are READ back, not set - see below
Treasuryproposals, pending approvals, confirm and void
Walletslist, get, create and assign, L1 balances
Exchangeorders, fills, matches, cancels, market orders, co-signed swaps
Commerceinvoices, webhooks, deposits, receipts, transactions

The gateway list and apiBaseUrl (the scanner origin for the invoice, webhook and payout-speed routes) are described in Two base URLs and Where each method runs. An /api method on a client with no apiBaseUrl refuses by name rather than sending the request to a gateway.

The client methods are grouped into extensions on VarnirClient (VarnirTransfers, VarnirLedgerKeys, VarnirWallets, VarnirExchange, VarnirCommerce). Importing package:varnir/varnir.dart brings them all in.

Not supported​

  • Derived balances. getBalances returns the identity stream, not a list of numbers: deriving them properly needs the token-registry read and the reserved-order calculation, and a balance that omits what is already committed to your own open orders reads as spendable when it is not.
  • Attaching a role, scopes or a spend policy to a key. addKey takes a public key and, optionally, its type, and nothing else. listLedgerKeys reads back whatever role, scopes and policy a key carries, and setApprovalThreshold arms or suspends the identity's approval gate, but the key itself has to be provisioned from an SDK that can express a role.
  • Generating a recovery phrase. deriveKeyFromPhrase recovers from a phrase; nothing upstream generates one.
  • Hold, recovery and waitForTransfer, as in the other ports.
  • Identities created under Varnir's retired phrase derivations. See Recovery phrases.

Beyond a send​

The rest of the surface reads the same way.

// A resting order is signed off-ledger and nothing is submitted: the maker
// commits until expireMs, and a taker fills it later while they are offline.
// Leave salt out for a fresh random one.
final order = await client.createOrder(
give: OrderLeg(chain: 'niles', amount: '1.5'),
want: OrderLeg(chain: 'sep', amount: '0.01'),
expireMs: expireMs,
);

// Posted to EVERY gateway, not the first that answers - each keeps its own
// book, so an order posted to one is invisible to a taker reading another.
await client.postOrder(order);
// Filling someone else's. The maker's signature is what makes an entry
// fillable: the contract verifies it, so no gateway can alter the terms.
final entry = (await client.getOrderBook()).first;
final signed = SignedOrder(
order: entry.order,
sig: entry.sig!,
orderHash: entry.orderHash,
);
// No options fills it whole. fillAmount takes part of it and minReceive is
// this taker's own floor, which the CONTRACT enforces rather than this client.
final umid = await client.fillOrder(signed);
// An invoice goes to the scanner API, not to the ledger: it is authenticated
// with the X-Varnir-* headers over a fixed route tag, not with $sigs.
// getWallet claims one (a signed Varnir.AssignOwner) if there is none yet.
final wallet = await client.getWallet('niles');
final invoice = await client.createInvoice(
wallets: [wallet.id],
name: 'Consulting, May',
amount: '25.00',
);
print(invoice.url);
// A new wallet for each invoice: one per chain, created (and assigned to
// you) before the invoice is raised. A payment into it can only match this
// invoice. The wallets are returned so you can show the payer an address.
// A 'native' invoice takes one chain; two or more need a token like USDT.
final invoice = await client.createInvoice(
newWallets: const NewInvoiceWallets(chains: ['sep', 'niles']),
name: 'Order 1042',
amount: '25.00',
currency: 'USDT',
);
for (final wallet in invoice.wallets!) {
print('${wallet.chain}: ${wallet.address}');
}
// A key for another process. The type is inferred from the key; pass it and
// a mismatch is refused rather than overridden. No role, scopes or policy.
await client.addKey(agentPublicKey, keyType: VarnirKeyType.mlDsa65);
// Confirming what a co-sign threshold held. It needs a DIFFERENT key with
// treasury authority - the client checks that before submitting anything,
// because a key without it would get a umid back for a brand-new proposal.
for (final approval in await client.listPendingApprovals()) {
if (!approval.isExpired) {
await client.confirmTransfer(approval.id);
}
}

Sending with a speed​

An L1 withdrawal can ask how quickly it should go out: PayoutSpeed.economy (within 24 hours, the default), PayoutSpeed.standard (within an hour) or PayoutSpeed.instant (straight away, on its own).

final result = await client.send(
chain: 'niles',
to: 'TSC9KBw...',
amount: '1.5',
speed: PayoutSpeed.standard,
);
print('${result.speed} recorded: ${result.speedRecorded}');

Payout speeds are not built on the network yet. Every withdrawal still goes out immediately and is charged at the Standard rate; the choice is only recorded.

  • result.speed is set only on an L1 withdrawal that went out: your choice, or PayoutSpeed.economy. A ledger settlement or a held proposal has null.
  • Only an explicit speed is sent anywhere, after the withdrawal is accepted, as a signed POST /api/withdrawals/speed. It never fails the send. result.speedRecorded is true if the API stored it, false if it could not, and null if you passed no speed.
  • The speed is not part of the signed transaction.
  • PayoutSpeed is a Dart enum, so there is no string to mistype. Parsing one from text with PayoutSpeed.values.byName throws Dart's own ArgumentError, not a VarnirError.

Verifying webhooks​

A receiver needs only the webhook secret, no client. Pass the raw body exactly as it arrived (a String or the bytes), and check it before you parse the JSON: re-serialised JSON is different bytes and will not match.

import 'package:varnir/varnir.dart';

bool accept(String secret, List<int> rawBody, Map<String, String> headers) {
// Header names are matched case-insensitively. A Map<String, List<String>>
// works too; a list with anything but exactly one entry counts as missing.
return verifyWebhookRequest(secret, rawBody, headers);
}

It returns false, never throws, for an empty secret, a missing or repeated header, a signature without v1=, a timestamp more than 300 seconds either side of your clock, or a signature that does not match. The comparison is constant-time. verifyWebhookSignature(secret, body, timestamp, signature) takes the two header values directly, plus optional toleranceSeconds: and now:.

Committed but not confirmed: OUTCOME_UNKNOWN​

send can throw after the transaction already committed: if the stream it created can't be read back in time (read-after-write lag, more likely under Activeledger v5's P2P commit), the SDK doesn't know whether it moved money or is held for co-sign. That's VarnirErrorCode.outcomeUnknown (wire string OUTCOME_UNKNOWN). Never resend. Confirm first:

try {
final result = await client.send(chain: 'niles', to: to, amount: '1.5', ref: 'invoice-42');
// ...
} on VarnirError catch (e) {
if (e.code != VarnirErrorCode.outcomeUnknown) rethrow;
print('commit unknown: ${e.committedUmid}');
// e.message names the created stream to check:
// await client.getTransferProposal(id)
// or look the committed umid up in the transaction history. Do not resend.
}

Only VarnirErrorCode.nodeUnreachable is blindly retryable (e.retryable). A rejection with e.afterTransportFailure set may have landed on an earlier node that lost its reply. See the error taxonomy for the full table.

Language-specific notes​

No double entry point​

toRawAmount('1.5', 18); // "1500000000000000000"
toRawAmount('0.1', 0); // throws FormatException: more precision than 0 decimals

The standalone function throws FormatException; the client methods catch it and throw a VarnirError with code validation before anything is sent. See Amounts are strings.

Map literals keep their order​

Dart map literals keep insertion order, and upstream's canonicalJson writes keys in that order, so each $tx shape in transactions.dart is written in the order it is signed. Build one by hand from a SplayTreeMap or anything else that sorts, and the keys come out stable, plausible and wrong. See Field order is the contract and $sigs is keyed by stream id.

Bitcoin addresses​

Check a Bitcoin address before paying it. Networks are btc, btc-testnet4 and btc-regtest; anything else, an address for the other network, a bad checksum or a witness version we do not pay is refused with VALIDATION. Bech32 comes back lower-case. Amounts need nothing new: Bitcoin is 8 decimal places, so "0.0001" is "10000" satoshis, and send() returns the ledger umid as on every chain (core contract section 5.1). send() itself refuses a destination that is not valid for the requested network (EVM with its EIP-55 checksum, Tron, Bitcoin) with VALIDATION, before the Varnir-known lookup and before anything is signed (section 5.2). The cases are generated from packages/btc-core.

isValidBtcAddress('btc-testnet4', 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'); // true
normaliseBtcAddress('btc-testnet4', 'TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX'); // lower-case
networkFamily('btc-testnet4'); // 'bitcoin', even while the network is disabled

Tests​

./copy-vectors.sh # copies the shared vectors in and regenerates networks_json.dart
dart pub get
dart test

127 tests: the shared golden vectors, key generation and phrase recovery (fixed expected values: the secp256k1 key every Varnir SDK derives from the test phrase, and the ml-dsa-65 seed from upstream's shared activeledger-seed-v1 scheme), the Falcon refusals without the add-on, webhook verification, node failover and read-back through lagging gateways, and a vector-driven test that drives every client method through a MockClient and compares the REQUEST it built (verb, base, path, body bytes including field order, header names) against requests.json. It iterates that file rather than a hand-written list, so a recorded method nobody ported is a red test rather than silence. test/client_test.dart covers what the recorder could not reach: the writes that follow a method's first request, send routing, payout speed and the refusals that must happen before anything is sent.

A missing vector file is a failure, not zero checked cases. See Golden vectors.