Skip to main content

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.

Falcon-512 is the default, as in the reference

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​

AmountstoRaw / fromRaw, string arithmetic only
Networksaliases, ledger codes, native decimals, gas reserve
Keysgeneration, recovery-phrase generation and recovery, type detection, on-wire form, the Varnir identity hash
Errorsthe full stable code taxonomy, with classification
Transactionsevery $tx shape, byte-identical to the reference
Signingdelegated to Activeledger's SDK: Falcon-512, ML-DSA-65 and secp256k1, chosen by the key's own tag
Transportthe gateway list and the scanner API, behind a Transport protocol
Onboardinggenerate a key, mint the identity, idempotently
Moneystreams, tokens, send, transfer, consolidate, deposits
Keys (ledger)add, remove, approval thresholds; role, scopes and policy are READ back, not set (see below)
Treasuryproposals, pending approvals, confirm and void
Walletslist, get, create and assign, L1 balances
Exchangeorders, fills, matches, cancels, market orders, co-signed swaps
Commerceinvoices, 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 a JSONValue, 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, and setApprovalThreshold(_:) 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.speed is set only on an L1 withdrawal that went out: your choice, or .economy. A ledger settlement or a held proposal has nil.
  • 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.speedRecorded is true if the API stored it, false if it could not (including a client with no apiBaseURL), and nil if you passed no speed.
  • The speed is not part of the signed transaction.
  • A speed from text goes through PayoutSpeed.parse(_:), which throws VALIDATION for 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 Transport and compares the REQUEST it built (verb, host, path, body bytes, header names) against requests.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.json and readback.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.