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.
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
| Amounts | toRawAmount / fromRawAmount, string arithmetic only |
| Networks | aliases, ledger codes, native decimals, gas reserve |
| Keys | generation, phrase recovery, type detection, on-wire form, the Varnir identity hash |
| Errors | the full stable code taxonomy, with classification |
| Transactions | every $tx shape, byte-identical to the reference |
| Signing | delegated, dispatching on the key's own tag |
| Transport | the gateway list and the scanner API, over an injectable http.Client |
| Onboarding | generate a key, mint the identity, idempotently |
| Money | streams, 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 |
| Treasury | proposals, pending approvals, confirm and void |
| Wallets | list, get, create and assign, L1 balances |
| Exchange | orders, fills, matches, cancels, market orders, co-signed swaps |
| Commerce | invoices, 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.
getBalancesreturns 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.
addKeytakes a public key and, optionally, its type, and nothing else.listLedgerKeysreads back whatever role, scopes and policy a key carries, andsetApprovalThresholdarms 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.
deriveKeyFromPhraserecovers 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.speedis set only on an L1 withdrawal that went out: your choice, orPayoutSpeed.economy. A ledger settlement or a held proposal hasnull.- 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.speedRecordedistrueif the API stored it,falseif it could not, andnullif you passed no speed. - The speed is not part of the signed transaction.
PayoutSpeedis a Dart enum, so there is no string to mistype. Parsing one from text withPayoutSpeed.values.byNamethrows Dart's ownArgumentError, not aVarnirError.
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.