The Varnir SDK for .NET
Varnir.Sdk is the C# port of @varnir/chain-client. Shared rules (amounts,
field order, $sigs, send results, recovery phrases, golden vectors) are on
SDK overview and backends.
The library targets netstandard2.0 and net8.0, so .NET Framework, Unity and
Mono consumers work alongside modern .NET.
Canonical JSON, transaction submission and all signing come from Activeledger's C# SDK. The one piece that is Varnir's own is deriving a public key from a private one, which upstream does not offer. See Cryptography comes from Activeledger.
What it covers
Full @varnir/chain-client surface: 51 public methods against its 45, with 114
tests. The byte-level core is proven byte-identical to the reference.
| Money | balances, send, transfer, held proposals, onboard |
| Keys | add/remove, roles, scopes, spend policy, expiry, thresholds |
| Treasury | pending approvals, watch, confirm, void |
| Wallets | list, get, create, L1 balances, consolidate |
| Commerce | invoices, webhooks, deposits, receipts, transactions |
| Exchange | orders, partial fills, market orders, matching, co-signed swaps |
For which backend each method uses, see Where each method runs.
Post-quantum identities work, Falcon-512 and ML-DSA-65, including key
generation. Signing.GenerateKey() defaults to Falcon-512 because that is what
the ledger onboards with.
Recovery phrases work for all three key types, through
Recovery.DeriveKeyPairFromPhrase, or Recovery.DerivePrivateKeyFromPhrase
for the classical pair on its own. Upstream's KeyPair.FromPhrase does the
whole derivation, phrase validation included. See
Recovery phrases, including the caution about
identities created before the switch.
Install
cd packages/sdk-dotnet
dotnet test tests/Varnir.Sdk.Tests/Varnir.Sdk.Tests.csproj
The Activeledger dependency
Activeledger.SDK comes from nuget.org, so nothing needs fetching first:
<PackageReference Include="Activeledger.SDK" Version="2.4.0" />
Use it
using Varnir.Sdk;
using Activeledger;
var raw = Amounts.ToRaw("1.5", 6); // "1500000"
var code = Networks.ToLedgerCode("niles"); // "TRX-NILE"
// The identity id is DERIVED from the public key, so this needs no network
// call and works before the identity exists on the ledger.
var identity = Keys.ComputeIdentity(publicKey);
var tx = Transactions.Send(
identity, code, "#native", raw, "TSC9KBw...",
contract: cryptoTransferContract, ns: "varnir");
// Serialise ONCE. These exact bytes are what gets signed and what gets sent -
// re-serialising is not guaranteed to reproduce them.
var message = Transactions.Canonical(tx);
var signature = Signing.SignPayload(message, privateKeyHex);
var envelope = Transactions.Envelope(tx, sigKey: identity, signature: signature);
Moving money
using var client = VarnirClient.ForPrivateKey(privateKey);
var result = await client.SendAsync(chain: "niles", amount: "1.5", to: "TSC9KBw...");
if (result.Settled) {
Console.WriteLine($"sent: {result.Umid}");
} else {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
Console.WriteLine($"held for a co-signature: {result.Proposal!.Id}");
}
SendAsync returns a SendResult; nothing is thrown when a send is held
(why). The transport
decides success by $summary.commit, not by $summary.errors
(why).
Sending with a speed
An L1 withdrawal takes an optional speed: a PayoutSpeed (Economy, the
default; Standard; Instant). PayoutSpeeds.All lists them, and
PayoutSpeeds.ToWire / FromWire convert to and from "economy" and so on.
var result = await client.SendAsync(
chain: "niles", amount: "1.5", to: "TSC9KBw...", speed: PayoutSpeed.Instant);
Console.WriteLine(result.Speed); // Instant; Economy if omitted; null off L1
Console.WriteLine(result.SpeedRecorded); // null unless speed was passed explicitly
Payout speeds are not built on the network yet: every withdrawal still goes
out immediately at the Standard rate, and the choice is only recorded. It is
never part of the signed $tx. An explicit speed is posted afterwards to
POST /api/withdrawals/speed, signed with the identity's own key (never an
attached API key) and best-effort. That call never throws, so
SpeedRecorded == false does not mean the send failed. A held proposal
and a ledger settlement carry no speed. An out-of-range value such as
(PayoutSpeed)7 throws VarnirErrorCode.Validation before anything is sent.
A new wallet for each invoice
CreateInvoiceAsync takes your existing wallet ids, or a NewInvoiceWallets
to give the invoice brand-new wallets of its own: one per chain, created and
assigned to you before the invoice is raised. A payment into one of them can
only match this invoice. A native invoice takes one chain; two or more need
a token such as USDT. The new wallets stay yours and count towards your plan's
wallet limit. They are priced in credits as ordinary wallets; see How credits work.
var invoice = await client.CreateInvoiceAsync(
new NewInvoiceWallets(new[] { "sep", "niles" }), "Order 1042", "25.00", currency: "USDT");
foreach (var wallet in invoice.Wallets!)
{
Console.WriteLine($"{wallet.Chain}: {wallet.Address}");
}
Verifying webhooks
No client needed, just the webhook secret. Pass the raw body, before parsing JSON:
using System;
using System.Collections.Generic;
using System.Text.Json;
using Varnir.Sdk;
static JsonDocument Handle(string secret, byte[] rawBody, IDictionary<string, string> headers)
{
if (!Webhooks.VerifyRequest(secret, rawBody, headers))
{
throw new UnauthorizedAccessException("not a genuine, recent Varnir delivery");
}
return JsonDocument.Parse(rawBody);
}
VerifyRequest reads X-Varnir-Timestamp and X-Varnir-Signature
case-insensitively from name/value pairs (a dictionary) or from
System.Net.Http.Headers.HttpHeaders. In ASP.NET Core, pass the two values
from Request.Headers to
Webhooks.VerifySignature(secret, rawBody, timestamp, signature) directly.
Either returns false (never throws) on a missing header, a signature
without v1=, a timestamp more than 300 seconds either side of your clock
(Webhooks.ToleranceSeconds), or a mismatch.
Trading
// Rest an order. Nothing is submitted - the maker signs off-ledger, and a
// taker carries this signature inside their own fill.
var order = await client.CreateOrderAsync(
give: new OrderLeg { Chain = "niles", Amount = "100" },
want: new OrderLeg { Chain = "sep", Amount = "0.05" },
expireMs: DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds());
await client.PostOrderAsync(order);
// Or take the best crossing price right now, instead of waiting.
var result = await client.MarketOrderAsync(
give: new OrderLeg { Chain = "sep", Amount = "0.05" },
wantNetwork: "niles",
maxSlippageBps: 50); // 0.5%
MarketOrderAsync ranks the book by each maker's own signed rate —
cross-multiplied, so there is no division and no rounding — and breaks ties by
which order rested first. The slippage floor is carried into the fill as
minReceive, so the contract enforces it rather than this client trusting
the book it just read.
A partial fill is rounded down to the order's dust-free step, because
give × fill must divide evenly by want or the settlement would leave a
fractional smallest unit. Both this SDK and the contract refuse that.
Committed but not confirmed: OutcomeUnknownException
SendAsync 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 OutcomeUnknownException
(VarnirErrorCode.OutcomeUnknown) — never resend. Confirm first:
try
{
var result = await client.SendAsync("niles", "1.5", to: "TSC9KBw...", @ref: "invoice-42", ct: ct);
}
catch (OutcomeUnknownException ex)
{
Console.WriteLine($"commit unknown: {ex.CommittedUmid}");
// ex.Message names the created stream to check:
// client.GetTransferProposalAsync(streamId, ct)
// or look the committed umid up in the transaction history. Do not resend.
}
A cancelled or expired CancellationToken during the read-back also surfaces
as OutcomeUnknownException, wrapping the OperationCanceledException as
InnerException rather than letting it escape as a bare cancellation — the
transaction already committed, and a caller that reads a timeout and retries
would pay twice.
See the error taxonomy for the full table.
Language-specific notes
No double or decimal overload
Amounts.ToRaw("1.5", 18); // "1500000000000000000"
Amounts.ToRaw("0.1", 0); // ArgumentException: rounds to zero
Amounts.ToRaw("1.0000001", 6); // ArgumentException: too much precision
See Amounts are strings.
Field order
Upstream's JsonObject preserves insertion order, so the order keys are Set
in is the order that gets signed. See
Field order is the contract and
$sigs is keyed by stream id.
Error codes on the wire use a table, not member names
C# names enum members in PascalCase; every other Varnir SDK spells these codes
UPPER_SNAKE, and that string is what crosses a process boundary.
var code = Errors.ClassifyRejectionMessage(rejection); // VarnirErrorCode.InsufficientBalance
ErrorCodes.ToWire(code); // "INSUFFICIENT_BALANCE"
ToWire is a hand-written table rather than a derived name, so a C# rename
cannot silently change a value that other languages match on.
Why BouncyCastle is still referenced
Only for deriving a public key from a private one. Signing and verification are
upstream's, and upstream also uses BouncyCastle internally: .NET's own ECDsa
returns a raw r || s pair rather than DER, guarantees no RFC 6979
deterministic k, and does not normalise s. All three would have to be built
on top of it, and the missing
low-S normalisation fails quietly. Do not sign
with ECDsa directly.
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.
Bitcoin.IsValid("btc-testnet4", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"); // true
Bitcoin.Normalise("btc-testnet4", "TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX"); // lower-case
Networks.NetworkFamily("btc-testnet4"); // "bitcoin", even while the network is disabled
Tests
The dotnet test command under Install runs the suite.
core.json is copied into the .NET build at build time from
packages/sdk-python. See Golden vectors. Start
at Amounts and Signing when reading the code.