Skip to main content

The Varnir SDK for Go

site.varnir/sdk is the Go port of @varnir/chain-client. Shared rules (amounts, field order, $sigs, send results, recovery phrases, golden vectors) are on SDK overview and backends.

Falcon-512 keys cannot sign

Activeledger's Go SDK implements ML-DSA-65 and secp256k1, not Falcon-512. As a result, this SDK cannot act for an identity whose owner key is Falcon-512. Such a key is refused when the client is constructed rather than at the first signature, while you can still pick a different key.

It does not stop Go generating a key, onboarding, or recovering from a phrase. Falcon-512 is the default Varnir's other clients choose, not a requirement of the chain: the onboard transaction names its key type in $i.otk.type, so an identity minted here as ml-dsa-65 is one Go owns outright.

Two supported paths:

  • Mint the identity here. GenerateKey() defaults to ml-dsa-65 for exactly this reason, and Onboard hands back a client holding the owner key.
  • Act for an existing Falcon identity through a scoped ml-dsa-65 or secp256k1 key added to it — a scoped key's type is chosen when it is added.

A Falcon seed can still be derived for another SDK to consume, through upstream's own recovery.DeriveSeed — not through this package, which mints and recovers only what it can sign with.

Cryptography​

Signing, key handling, canonical JSON (al.CanonicalJSON) and public-key derivation all come from Activeledger's Go SDK. Go's upstream has eckeys.FromSeed, and for secp256k1 the seed is the private scalar, so unlike the .NET and Kotlin ports this one keeps no public-key derivation of its own. Key generation and phrase recovery use upstream's pqkeys, eckeys and recovery. See Cryptography comes from Activeledger.

Install​

go get site.varnir/sdk

Go 1.25 or later. No v2 tag of Activeledger's Go SDK before v2.2.1 is installable (the module path was wrong), so pin at least v2.2.1.

Use it​

Start from nothing — a key, an identity, and a client that owns it:

import varnir "site.varnir/sdk"

// ml-dsa-65 by default, NOT falcon-512 as the TypeScript and Kotlin SDKs
// default: defaulting to a type this SDK cannot sign with would hand back a
// key it then refuses. Keep key.PrivateKey — nothing can return it later.
key, err := varnir.GenerateKey("")

// Computes the identity from the key, asks whether it already exists, and
// submits only if it does not — so calling it twice with the same key costs
// one read and no transaction. Options.Identity must be empty: the identity
// is computed here, not supplied.
client, err := varnir.Onboard(ctx, varnir.Options{
PrivateKey: key.PrivateKey,
Nodes: []string{gatewayURL},
OnboardContract: onboardContractID,
})

Or act for an identity that already exists:

client, err := varnir.New(varnir.Options{
Identity: myIdentity,
PrivateKey: myAgentKey, // ml-dsa-65 or secp256k1
Nodes: []string{gatewayURL},
TransferContract: transferContractID,
})
if err != nil {
return err
}

res, err := client.Send(ctx, "niles", "TSC9KBw...", "1.5", "")
if err != nil {
return err
}
if res.Settled {
log.Printf("sent: %s", res.UMID)
} else if res.Held {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
log.Print("held for a co-signature — approval needed")
}

Send returns a SendResult; a held send is not an error (why).

Sending with a speed​

An L1 withdrawal takes an optional Speed on SendOptions: one of varnir.PayoutSpeeds() — PayoutSpeedEconomy (the default when empty), PayoutSpeedStandard or PayoutSpeedInstant.

res, err := client.SendWithOptions(ctx, "niles", "TSC9KBw...", "1.5", "",
varnir.SendOptions{Speed: varnir.PayoutSpeedInstant})
if err != nil {
return err
}
log.Println(res.Speed) // "instant"; "economy" if omitted; "" off L1 or when Held
if res.SpeedRecorded != nil { // nil unless Speed was set explicitly
log.Println(*res.SpeedRecorded)
}

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 and best-effort. It never returns an error, so *res.SpeedRecorded == false does not mean the send failed. An unknown value returns ErrValidation before anything is sent.

Verifying webhooks​

No client needed, just the webhook secret. Pass the raw body, before parsing JSON:

func webhook(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil || !varnir.VerifyWebhookRequest(secret, raw, r.Header) {
http.Error(w, "not a genuine, recent Varnir delivery", http.StatusUnauthorized)
return
}
var event map[string]any
_ = json.Unmarshal(raw, &event) // only now
w.WriteHeader(http.StatusNoContent)
}
}

It reads X-Varnir-Timestamp and X-Varnir-Signature case-insensitively. It returns false (never panics) on a missing header, a signature without v1=, a timestamp more than 300 seconds either side of your clock, or a mismatch. VerifyWebhookSignature(secret, body, timestamp, signature) takes the two values directly; both accept the body as a string or []byte, and an optional varnir.WebhookVerifyOptions{ToleranceSeconds, Now}.

What is covered​

AmountsToRaw / FromRaw, string arithmetic only
Networksaliases, ledger codes, native decimals, gas reserve
Keysgeneration, phrase recovery, type detection, on-wire form, the Varnir identity hash
OnboardingOnboard mints an identity and returns a client holding its owner key
Errorsthe full stable code taxonomy, with classification
Transactionsevery $tx shape, byte-identical to the reference
Signingdelegated, dispatching on the key's own tag
Transportgateway failover, the commit-decides submission rule, and the signed X-Varnir-* API path
Moneybalances, tokens, send, transfer, consolidate
Keys (ledger)add, remove, list with role and policy, approval thresholds
Treasuryproposals, pending approvals, confirm and void
Walletslist, get, create and assign, L1 balances, deposit status
Exchangeorders, fills, matches, cancels, market orders, co-signed swaps
Commerceinvoices, webhooks, deposits, receipts, transactions

Nodes are the gateways; ApiBaseUrl is the scanner origin for wallet, invoice and webhook routes. See Two base URLs and Where each method runs.

Recovery phrases​

DeriveKeyPairFromPhrase recovers either key type this SDK can use (ml-dsa-65, secp256k1) through upstream's derivation, with the BIP-39 checksum verified. The test pins the private key, compressed public key and identity that Activeledger's Go SDK and the TypeScript reference both produce. See Recovery phrases, including the caution about identities created before the switch. There is no phrase generator.

Not supported​

  • Roles, scopes and spend policies on AddKey. It takes a public key and its type, and nothing else. A scoped off-ledger key or a capped agent key has to be attached with an SDK that carries those arguments; this one can add and remove authorities, read them back with ListLedgerKeys, and set approval thresholds.
  • A derived GetBalances. It 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.

Beyond a send​

The rest of the surface uses the same client.

// Rest an order on the book, then post it. CreateOrder signs locally and
// submits nothing, so the maker's signature is made before anything leaves
// the process.
order, err := client.CreateOrder(ctx, varnir.CreateOrderOptions{
Give: varnir.OrderLeg{Chain: "niles", Amount: "10"},
Want: varnir.OrderLeg{Chain: "sep", Amount: "0.05"},
ExpireMs: time.Now().Add(time.Hour).UnixMilli(),
})
if err != nil {
return err
}
if err := client.PostOrder(ctx, order); err != nil {
return err
}

// Take someone else's. A fill settles both legs in one transaction or
// neither, and the maker is never asked to sign again.
umid, err := client.FillOrder(ctx, resting, varnir.FillOptions{})

// Raise an invoice. This one goes to the scanner API, so it needs
// ApiBaseUrl and is signed with the X-Varnir-* headers rather than $sigs.
invoice, err := client.CreateInvoice(ctx, varnir.CreateInvoiceOptions{
Wallets: []string{walletID},
Name: "Order 1042",
Amount: "25",
})

// Or give the invoice a new wallet of its own on each chain. Nobody has paid
// into these before, so a payment can only be for this invoice. A
// native-coin invoice takes one chain; USDT can take several.
invoice, err = client.CreateInvoice(ctx, varnir.CreateInvoiceOptions{
NewWallets: &varnir.CreateInvoiceNewWallets{Chains: []string{"sep", "niles"}},
Name: "Order 1043",
Amount: "25",
Currency: "USDT",
})
// invoice.Wallets[i].Address is where the payer sends.

// Attach a scoped key, then arm co-signing per asset. The rules are a SLICE
// rather than a map because they sit inside the signed message, and Go
// randomises map order - the same policy would sign differently each call.
_, err = client.AddKey(ctx, agentPublicKeyHex, varnir.Secp256k1)
_, err = client.SetApprovalThresholdPerAsset(ctx, []varnir.AssetThreshold{
{Asset: "TRX-NILE", Threshold: "1000000"},
{Asset: "SEP", Threshold: "-1"}, // -1 disarms this asset
})

// A held transfer, from the other side.
pending, err := client.ListPendingApprovals(ctx)
umid, err = client.ConfirmTransfer(ctx, pending[0].ID)
Per-asset thresholds do not fail closed on an older contract

A deployment whose Varnir.Keys predates per-asset rules ignores them and reads the top-level value that is no longer there. That does not refuse the write — it silently disarms the threshold altogether and leaves every send unapproved. Check what the target deployment runs before moving an armed identity onto it.

Committed but not confirmed: OUTCOME_UNKNOWN​

A Send/SendWithOptions call can commit on the ledger and still return an error: if the stream it created can't be read back within the retry budget (read-after-write lag, more likely under Activeledger v5's P2P commit), the client doesn't know whether it moved money or is held for co-sign. That's ErrOutcomeUnknown, not a failed send — never resend. Confirm first:

result, err := client.SendWithOptions(ctx, "niles", to, "1.5", "", varnir.SendOptions{Ref: "invoice-42"})
if err != nil {
var ve *varnir.Error
if errors.As(err, &ve) && ve.Code == varnir.ErrOutcomeUnknown {
log.Printf("commit unknown: umid=%s, cause=%v", ve.CommittedUmid, ve.Cause)
// ve.Message names the created stream to check:
// client.GetTransferProposal(ctx, id)
// or look the committed umid up in the transaction history. Do not resend.
return
}
}

A ctx cancellation or deadline during the read-back also surfaces as ErrOutcomeUnknown, with ve.Cause wrapping ctx.Err() (errors.Is(err, context.DeadlineExceeded) still holds) — never as a bare timeout, because the transaction already committed and a timeout invites a retry that pays twice.

See the error taxonomy for the full table.

Language-specific notes​

No float64 overload​

varnir.ToRaw("1.5", 18) // "1500000000000000000"
varnir.ToRaw("0.1", 0) // error: more precision than this asset's 0 decimals

See Amounts are strings.

Never build transactions with a map​

Transactions are built with al.Object, whose Set preserves insertion order. Go randomises map iteration order, so a map-built transaction signs differently on each call; this is also why per-asset threshold rules are a slice. Optional fields are omitted, never set to "": {"to":""} and an omitted to are different bytes. See Field order is the contract and $sigs is keyed by stream id.

Do not encode al.Object with encoding/json​

encoding/json on an al.Object returns {} because its fields are unexported, so a transaction encoded that way is submitted as a well-formed empty object, which a node accepts and ignores. Use the SDK's serialisation.

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.

ok := varnir.IsValidBtcAddress("btc-testnet4", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") // true
canonical, err := varnir.NormaliseBtcAddress("btc-testnet4", "TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX") // lower-case
family, _ := varnir.NetworkFamily("btc-testnet4") // "bitcoin", even while the network is disabled

Tests​

./copy-vectors.sh
go test ./...

./copy-vectors.sh copies core.json in from packages/sdk-python and fails if the source is missing. See Golden vectors.