Skip to main content

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.

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.

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.