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.
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 toml-dsa-65for exactly this reason, andOnboardhands back a client holding the owner key. - Act for an existing Falcon identity through a scoped
ml-dsa-65orsecp256k1key 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
| Amounts | ToRaw / FromRaw, string arithmetic only |
| Networks | aliases, ledger codes, native decimals, gas reserve |
| Keys | generation, phrase recovery, type detection, on-wire form, the Varnir identity hash |
| Onboarding | Onboard mints an identity and returns a client holding its owner key |
| 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 | gateway failover, the commit-decides submission rule, and the signed X-Varnir-* API path |
| Money | balances, tokens, send, transfer, consolidate |
| Keys (ledger) | add, remove, list with role and policy, approval thresholds |
| Treasury | proposals, pending approvals, confirm and void |
| Wallets | list, get, create and assign, L1 balances, deposit status |
| Exchange | orders, fills, matches, cancels, market orders, co-signed swaps |
| Commerce | invoices, 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 withListLedgerKeys, 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)
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.