The Varnir SDK for Kotlin and Java
varnir-sdk is the JVM port of @varnir/chain-client. Shared rules (amounts,
field order, $sigs, send results, recovery phrases, golden vectors) are on
SDK overview and backends.
One artifact serves both languages: every entry point is @JvmStatic, so Java
callers use the same API without a wrapper.
Canonical JSON, transaction submission and all signing come from Activeledger's JVM 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, with 71 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.
Build
cd packages/sdk-jvm
./gradlew test
Java 17 or later. No Gradle installation needed — the wrapper is committed.
The Activeledger dependency
SDK-JVM is on Maven Central, so mavenCentral() is the only repository
needed:
repositories {
mavenCentral()
}
dependencies {
api("io.github.activeledger:activeledger:1.0.0")
}
The artifact was called activeledger-sdk while it shipped on GitHub
releases; from 1.0.0 it is activeledger.
Use it
import site.varnir.sdk.*
val raw = Amounts.toRaw("1.5", 6) // "1500000"
val code = Networks.toLedgerCode("niles") // "TRX-NILE"
val tx = Transactions.send(
identity = myIdentity,
network = code,
token = "#native",
amount = raw,
to = "TSC9KBw...",
contract = cryptoTransferContract,
namespace = "varnir",
)
// Serialise ONCE. These exact bytes are what gets signed and what gets sent -
// re-serialising is not guaranteed to reproduce them.
val message = Transactions.canonical(tx)
val signature = Signing.signPayload(message, privateKeyHex)
val envelope = Transactions.envelope(tx, sigKey = myIdentity, signature = signature)
From Java:
String raw = Amounts.toRaw("1.5", 6);
String code = Networks.toLedgerCode("niles");
VarnirErrorCode outcome = Errors.classifyRejectionMessage(rejection);
Moving money
val client = VarnirClient.forPrivateKey(privateKey)
val result = client.send(chain = "niles", amount = "1.5", to = "TSC9KBw...")
if (result.settled) {
println("sent: ${result.umid}")
} else {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
println("held for a co-signature: ${result.proposal!!.id}")
}
send returns a SendResult; nothing is thrown when a send is held
(why).
Calls are blocking. A ledger write is a round trip to consensus; the caller is better placed than the library to choose which thread waits on it.
Payout speed
An L1 withdrawal can ask how quickly it goes out. Payout speeds are not built on the network yet — every withdrawal still goes out immediately at the Standard rate — so the choice is only recorded.
val result = client.send(chain = "niles", amount = "1.5", to = "TSC9KBw...", speed = PayoutSpeed.INSTANT)
println("${result.speed} recorded=${result.speedRecorded}")
PayoutSpeed is ECONOMY (the default), STANDARD or INSTANT;
PayoutSpeed.fromWire("instant") parses a string and throws
VarnirErrorCode.VALIDATION for anything else. Only an L1 withdrawal that
went out carries speed. An explicit speed is also posted, best-effort and
signed by the onboarding key, to POST /api/withdrawals/speed;
speedRecorded says whether that worked, and false never means the send
failed. The speed is never part of the signed $tx.
A new wallet for each invoice
createInvoice 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.
val invoice = client.createInvoice(
NewInvoiceWallets(listOf("sep", "niles")), name = "Order 1042", amount = "25.00", currency = "USDT",
)
invoice.wallets?.forEach { println("${it.chain}: ${it.address}") }
Verifying webhooks
No client needed — just the webhook secret. Verify the raw body, before parsing JSON:
val ok = Webhooks.verifyWebhookRequest(secret, rawBody, headers) // rawBody: ByteArray or String
if (!ok) return 401
headers is a Map<String, *> of String or single-element list values,
read case-insensitively. Webhooks.verifyWebhookSignature(secret, body, timestamp, signature) takes the header values directly. Both return false
— never throw — on a bad or stale (more than 300 s either way) delivery.
Committed but not confirmed: OUTCOME_UNKNOWN
send can throw VarnirException 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.OUTCOME_UNKNOWN — never resend. Confirm first:
try {
val result = client.send(chain = "niles", amount = "1.5", to = "TSC9KBw...", ref = "invoice-42")
} catch (e: VarnirException) {
if (e.code == VarnirErrorCode.OUTCOME_UNKNOWN) {
println("commit unknown: ${e.committedUmid}")
// e.message names the created stream to check:
// client.getTransferProposal(streamId)
// or look the committed umid up in the transaction history. Do not resend.
} else {
throw e
}
}
An interrupted thread during the read-back also surfaces as
VarnirErrorCode.OUTCOME_UNKNOWN, with the InterruptedException as
cause rather than escaping bare — the transaction already committed, and a
caller that treats it as a timeout and retries would pay twice.
See the error taxonomy for the full table.
Language-specific notes
No Double overload
Amounts.toRaw("1.5", 18) // "1500000000000000000"
Amounts.toRaw("0.1", 0) // IllegalArgumentException: rounds to zero
See Amounts are strings.
Field order uses LinkedHashMap
JsonObject is backed by a LinkedHashMap, so the order keys are put in is
the order that gets signed. 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.
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
core.json is copied into the Kotlin build at build time from
packages/sdk-python. See Golden vectors. Start
at Amounts and Signing when reading the code.