Skip to main content

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.

Moneybalances, send, transfer, held proposals, onboard
Keysadd/remove, roles, scopes, spend policy, expiry, thresholds
Treasurypending approvals, watch, confirm, void
Walletslist, get, create, L1 balances, consolidate
Commerceinvoices, webhooks, deposits, receipts, transactions
Exchangeorders, 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.