The Varnir SDK for Swift
Varnir is the Swift port of @varnir/chain-client, for iOS and macOS apps
and Swift servers. Shared rules (amounts, field order, $sigs, send results,
recovery phrases, golden vectors) are on
SDK overview and backends.
This SDK signs Falcon-512, ML-DSA-65 and secp256k1, through
Activeledger's own Swift SDK and its ActiveledgerFalcon add-on. The add-on
is registered for you before the first key operation, so there is no setup
call. Keys.generateKey() and Keys.deriveKeyPair(fromPhrase:) default to
falcon-512, the same as the TypeScript reference and the wallet.
Install
Swift Package Manager, from a checkout of this repository. The package is not published yet, so it is a path dependency:
// Package.swift
dependencies: [
.package(path: "path/to/varnir/packages/varnir-swift"),
],
targets: [
.target(name: "YourApp", dependencies: [.product(name: "Varnir", package: "varnir-swift")]),
]
SwiftPM names a path dependency after its directory, so the package is
"varnir-swift", while the product and the module you import are Varnir.
Swift 6.1 or later (Xcode 16.3+); iOS 16 or macOS 13 or later. Linux works too. The floor is upstream's: Activeledger's SDK pins swift-secp256k1 0.23.2, which needs Swift 6.1 tools.
Activeledger's SDK is pinned exactly (1.0.2), because it carries the
cryptography and the golden vectors are re-checked on every bump.
Cryptography
Signing, key generation, BIP-39 phrase generation and recovery, and canonical
JSON all come from Activeledger's Swift SDK, SDK-Swift, with its
ActiveledgerFalcon add-on for Falcon-512. This SDK chooses the key-string
encoding and the default, not the algorithm. See
Cryptography comes from Activeledger.
SHA-256 for the identity hash and HMAC-SHA256 for webhooks come from swift-crypto. The one piece of JSON handling this package keeps is a parser that preserves key order, because upstream serialises JSON but does not parse it.
JSONValue and KeyType are upstream's types, re-exported, so a value moves
between this SDK and Activeledger's without conversion. Legacy RSA, which
Activeledger also knows, is refused by name; no Varnir identity uses it.
Post-quantum signing is hedged, as in the reference: two signatures over the same message differ, and both verify. Never byte-compare one.
Store keys in the Keychain, or your platform's equivalent. The SDK takes a key string, and never persists or transmits it. Where it lives is up to the app.
Use it
import Varnir
var options = VarnirClient.Options(identity: identity, privateKey: privateKey)
options.gateways = ["https://gate1-testnet.varnir.site"]
options.apiBaseURL = "https://scanner.varnir.site"
options.transferContract = cryptoTransferId
options.tokenRegistryStream = registryStreamId
let client = try VarnirClient(options)
// Everything after `amount` has a default: `token` (nil is the chain's own
// coin), `settlement` (.auto), `ref`, `repeat`, and the payout `speed`
// (nil is .economy).
let result = try await client.send(chain: "niles", to: "TSC9KBw...", amount: "1.5")
if result.settled {
print("sent: \(result.umid)")
} else if let proposal = result.proposal {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
print("held for a co-signature: \(proposal.id)")
}
send returns a SendResult; a held send is not an error
(why).
Starting from nothing
import Varnir
// falcon-512 by default, as in the reference.
let key = try Keys.generateKey()
var options = VarnirClient.Options(identity: "", privateKey: key.privateKey)
options.gateways = ["https://gate1-testnet.varnir.site"]
options.onboardContract = onboardContractId
// The identity is computed from the key, sha256(publicKey + "identity"),
// the same name Varnir.Onboard gives the stream. So the empty one above is
// replaced rather than trusted. Idempotent: a key already on the ledger
// submits nothing at all.
let client = try await VarnirClient.onboard(options)
Keep key.privateKey; nothing can hand it back. Or keep a recovery phrase,
which this SDK can also generate (upstream's generator):
// A standard 12-word BIP-39 phrase (128 bits of entropy).
let phrase = Keys.generateRecoveryPhrase()
// Any key type recovers from it, byte for byte what the other SDKs derive.
let key = try Keys.deriveKeyPair(fromPhrase: phrase) // falcon-512
let classical = try Keys.deriveKeyPair(fromPhrase: phrase, type: .secp256k1)
// Wordlist AND checksum, after trimming, lowercasing and collapsing spaces.
let ok = Keys.isValidRecoveryPhrase(phrase)
A phrase that fails its checksum is refused rather than derived. See Recovery phrases for identities created before the switch.
Contract ids and the token registry stream have no defaults: they are per-deployment addresses, and a method that needs one it was not given fails naming the option to set. See Two base URLs.
Or sign without a client
The offline half is public too, for a service that submits through something of its own:
import Varnir
let chain = Networks.chainConfig("niles")!
let raw = try toRaw("1.5", decimals: chain.nativeDecimals) // "1500000"
// The client appends the "@1" version wildcard to a contract id for you.
// Building the transaction yourself, you name it.
let tx = Transactions.send(
identity: identity, network: chain.ledgerCode, token: nativeTokenKey, amount: raw, to: "TSC9KBw...",
contract: "\(transferContract)@1", namespace: "varnir", expire: expireMs)
// Serialise ONCE. These exact bytes are what gets signed and what gets sent.
// canonicalString throws for a number JavaScript could not represent.
let message = try tx.canonicalString
let signature = try Signing.signPayload(message, privateKey: privateKey)
// $sigs is keyed by the identity stream, not by "owner".
let envelope = Transactions.envelope(tx, sigs: [(key: identity, signature: signature)])
let body = try envelope.canonicalString
What is covered
| Amounts | toRaw / fromRaw, string arithmetic only |
| Networks | aliases, ledger codes, native decimals, gas reserve |
| Keys | generation, recovery-phrase generation and recovery, type detection, on-wire form, the Varnir identity hash |
| Errors | the full stable code taxonomy, with classification |
| Transactions | every $tx shape, byte-identical to the reference |
| Signing | delegated to Activeledger's SDK: Falcon-512, ML-DSA-65 and secp256k1, chosen by the key's own tag |
| Transport | the gateway list and the scanner API, behind a Transport protocol |
| Onboarding | generate a key, mint the identity, idempotently |
| Money | streams, tokens, send, transfer, consolidate, deposits |
| Keys (ledger) | add, remove, approval thresholds; role, scopes and policy are READ back, not set (see below) |
| Treasury | proposals, pending approvals, confirm and void |
| Wallets | list, get, create and assign, L1 balances |
| Exchange | orders, fills, matches, cancels, market orders, co-signed swaps |
| Commerce | invoices, webhooks, deposits, transactions |
The gateway list and apiBaseURL (the scanner origin for invoice, webhook
and payout-speed routes) are described in
Two base URLs and
Where each method runs.
Not supported
- Derived balances.
getBalances()returns the identity stream as aJSONValue, 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. - Attaching a role, scopes or a spend policy to a key.
addKey(publicKey:keyType:)takes a public key and its type, and nothing else.listLedgerKeys()reads back whatever role, scopes and policy a key carries, andsetApprovalThreshold(_:)arms or suspends the identity's approval gate, but the key itself has to be provisioned from an SDK that can express a role. - Identities created under Varnir's retired phrase derivations. See Recovery phrases.
Beyond a send
The rest of the surface reads the same way.
// A resting order is signed off-ledger and nothing is submitted: the maker
// commits until `expireMs`, and a taker fills it later while they are
// offline. With no `salt`, a fresh random one is used.
let leg = OrderLeg(chain: "niles", amount: "1.5")
let order = try await client.createOrder(give: leg, want: leg, expireMs: expireMs)
// Posted to EVERY gateway, not the first that answers. Each keeps its own
// book, so an order posted to one is invisible to a taker reading another.
try await client.postOrder(order)
// Filling someone else's. The maker's signature is what makes an entry
// fillable: the contract verifies it, so no gateway can alter the terms.
let entry = try await client.getOrderBook()[0]
guard let sig = entry.sig else { return } // no signature, not fillable
let signed = SignedOrder(order: entry.order, sig: sig, orderHash: entry.orderHash)
// No `fillAmount` fills it whole. `minReceive` is this taker's own floor,
// which the CONTRACT enforces rather than this client.
let umid = try await client.fillOrder(signed)
// An invoice goes to the scanner API, not to the ledger: it is
// authenticated with the X-Varnir-* headers over a fixed route tag, not
// with $sigs.
let wallet = try await client.getWallet(chain: "niles")
let invoice = try await client.createInvoice(
NewInvoice(walletIds: [wallet.id], name: "Consulting, May", amount: "25.00"))
print(invoice.url)
// A new wallet for each invoice: one per chain, created (and assigned to
// you) before the invoice is raised. A payment into it can only match this
// invoice. The wallets are returned so you can show the payer an address.
// A "native" invoice takes one chain; two or more need a token like USDT.
let order = try await client.createInvoice(
NewInvoice(
newWallets: NewInvoiceWallets(chains: ["sep", "niles"]), name: "Order 1042", amount: "25.00",
currency: "USDT"))
for wallet in order.wallets ?? [] {
print("\(wallet.chain): \(wallet.address)")
}
// A scoped key for another process. It carries no role or scopes: addKey
// takes a public key and its type, nothing else (see Not supported).
let agentKey = try Keys.generateKey(.mlDsa65)
_ = try await client.addKey(publicKey: agentKey.publicKey, keyType: .mlDsa65)
// Confirming what a co-sign threshold held. It needs a DIFFERENT key with
// treasury authority. The client checks that, and refuses a proposal this
// same key raised, before submitting anything.
for approval in try await client.listPendingApprovals() where !approval.isExpired {
_ = try await client.confirmTransfer(approval.id)
}
Sending with a speed
An L1 withdrawal can ask how quickly it should go out: .economy (within 24
hours, the default), .standard (within an hour) or .instant (straight
away, on its own).
let result = try await client.send(chain: "niles", to: "TSC9KBw...", amount: "1.5", speed: .standard)
print("\(String(describing: result.speed)) recorded: \(String(describing: result.speedRecorded))")
Payout speeds are not built on the network yet. Every withdrawal still goes out immediately and is charged at the Standard rate; the choice is only recorded.
result.speedis set only on an L1 withdrawal that went out: your choice, or.economy. A ledger settlement or a held proposal hasnil.- Only an explicit speed is sent anywhere, after the withdrawal is accepted, as
a signed
POST /api/withdrawals/speed. It never fails the send.result.speedRecordedistrueif the API stored it,falseif it could not (including a client with noapiBaseURL), andnilif you passed no speed. - The speed is not part of the signed transaction.
- A speed from text goes through
PayoutSpeed.parse(_:), which throwsVALIDATIONfor anything else, before anything is sent.
Verifying webhooks
A receiver needs only the webhook secret, no client. Pass the raw body exactly as it arrived, and check it before you parse the JSON: re-serialised JSON is different bytes and will not match.
import Foundation
import Varnir
func accept(secret: String, rawBody: Data, headers: [String: String]) -> Bool {
// Header names are matched case-insensitively. An overload takes
// [(name: String, value: String)], where a repeated header counts as
// missing.
Webhooks.verifyRequest(secret: secret, body: rawBody, headers: headers)
}
It returns false, never throws, for a missing or repeated header, a
signature without v1=, a timestamp more than 300 seconds either side of your
clock, or a signature that does not match. Webhooks.verifySignature takes
the two header values directly, and both take an optional tolerance and
now.
Committed but not confirmed: OUTCOME_UNKNOWN
send 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 ErrorCode.outcomeUnknown. Never resend.
Confirm first:
do {
let result = try await client.send(chain: "niles", to: to, amount: "1.5", ref: "invoice-42")
print(result.umid)
} catch let error as VarnirError where error.code == .outcomeUnknown {
print("commit unknown: \(error.committedUmid ?? "?")")
// error.message names the created stream to check with
// client.getTransferProposal(id), or look the committed umid up in the
// transaction history. Do not resend.
}
See the error taxonomy for the full table.
Language-specific notes
async, and a Transport you can replace
Every network call is async, and nearly all of them throw. The exceptions
answer rather than throw: identityExists returns false and getTransfer
returns nil when no gateway can say. Key generation, signing and webhook
verification are synchronous. VarnirClient.Options.transport takes anything
conforming to the Transport protocol. Only send(_:) is required, and the
failover-aware methods have single-host defaults, so a caller with their own
URLSession, a proxy or a queue can supply one. The tests use a recording
implementation of that protocol, not a mock of HTTP. The default is
HTTPTransport, over URLSession.
No Double entry point
let wei = try toRaw("1.5", decimals: 18) // "1500000000000000000"
_ = try? toRaw("0.1", decimals: 0) // throws: more precision than 0 decimals
See Amounts are strings.
An ordered JSONObject, not a Dictionary
Transactions are built as JSONObject, which keeps insertion order. A Swift
Dictionary has no stable order, and sorting the keys gives a stable,
plausible, wrong one. 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(network: "btc-testnet4", address: "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") // true
try Bitcoin.normalise(network: "btc-testnet4", address: "TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX") // lower-case
Networks.family("btc-testnet4") // "bitcoin", even while the network is disabled
Tests
./copy-vectors.sh # copies the shared vectors in; they are not committed here
swift test
74 tests. They cover:
- the shared golden vectors: amounts, chains, identities, secp256k1 signatures byte for byte, transaction shapes, rejection classification and repeat-payment parsing;
- ML-DSA key pairs and signatures, and recovery-phrase derivation, checked at Varnir's boundary against values from the reference's own libraries (upstream's own tests check them against Activeledger's cross-language vectors);
- a vector-driven test that drives every client method through a recording
Transportand compares the REQUEST it built (verb, host, path, body bytes, header names) againstrequests.json. It iterates that file rather than a hand-written list, so a recorded method nobody ported is a failing test rather than silence; - node failover on submit and reading back created streams, over real
localhost sockets, against
failover.jsonandreadback.json; - webhook verification against HMACs computed outside Swift, send routing, the payout-speed record, and the client's guard rails.
The suite asserts explicitly that the leading-dot decimal ".5" is accepted.
See Golden vectors.
What has not been checked
- It has not been built with Xcode or run on macOS or iOS. It has been built and tested on Linux only.
- Nothing has been sent to a live gateway. Every network test uses a recording transport or localhost sockets.
- No Falcon-512 key pair or signature made by the TypeScript reference is pinned. Falcon is checked by round trips (generate, sign, verify, recover) and by upstream's own cross-language vectors.