Swift SDK¶
The Swift SDK builds, signs and sends Activeledger transactions from Swift code. It is a port of the JavaScript SDK: the same key file format and byte-for-byte the same signatures, checked against the JavaScript SDK's test vectors. It ships two Swift Package Manager products: Activeledger (secp256k1, ML-DSA-65 and legacy RSA) and ActiveledgerFalcon, an add-on that brings Falcon-512 and its native code. It needs Swift 5.9 or later (swift-tools-version: 5.9) and supports iOS 15, macOS 12, tvOS 15, watchOS 8 and Linux. The README says the post-quantum key types need Activeledger 4.7.0 or later on the network; the SDK does not state a minimum node version for secp256k1.
Install¶
The package is installed from its GitHub URL through Swift Package Manager. It is not on a package registry. The tags are v1.0.0, v1.0.1 and v1.0.2. Add the dependency to Package.swift:
.package(url: "https://github.com/activeledger/SDK-Swift.git", from: "1.0.2")
Then add the product to your target:
.product(name: "Activeledger", package: "SDK-Swift")
For Falcon-512 as well, add the add-on:
.product(name: "ActiveledgerFalcon", package: "SDK-Swift")
In Xcode, use File > Add Package Dependencies and paste the repository URL.
Note
The fixes described on this page for response checking, PEM handling, key validation and connection timeouts are on the main branch but are not in v1.0.2, the latest tag. Until the next tag, depend on branch: "main" to get them.
On Apple platforms the SDK sends requests with URLSession. On Linux it uses AsyncHTTPClient instead, so a Linux build also pulls in swift-nio. Apple builds do not.
Quick start¶
This connects to a node, makes a key, onboards it (see Keys, identities and namespaces) and prints the new identity:
import Activeledger
let ledger = try Activeledger("http://localhost:5260")
let key = try ledger.generateKey("identity")
try await ledger.onboard(key)
print(key.identity!) // the new identity's stream id
Activeledger holds four public handlers: keys, transactions, payloads and connection. generateKey, onboard and send are shorthands over them. There is also Activeledger("http", "localhost", 5260) for scheme, address and port separately. Both initialisers throw ActiveledgerError.invalidArgument if the URL is not http or https.
Keys¶
A Key has a name, a type, a key holding publicKey and privateKey strings, an identity (nil until onboarded) and an optional phrase.
KeyType |
Wire string | Encoding | Notes |
|---|---|---|---|
.secp256k1 |
secp256k1 |
0x hex |
The default. Signatures are about 70 bytes. |
.mlDsa65 |
ml-dsa-65 |
base64 | 3309-byte signatures. In the core product. |
.falcon512 |
falcon-512 |
base64 | signatures of about 650 bytes, varying. Needs ActiveledgerFalcon. |
.rsa |
rsa |
PEM | Legacy. Existing PEM keys sign and verify; new ones cannot be generated. |
let keys = KeyHandler()
let ec = try keys.generateKey("me") // secp256k1, 65-byte public key
let small = try keys.generateKey("me", compressed: true) // 33-byte public key
let pq = try keys.generateKey("me", type: .mlDsa65)
ec.key.publicKey // "0x04..." - this goes on the ledger
ec.key.privateKey // keep this
compressed is ignored by the post-quantum types. KeyType.fromWire("bitcoin") and "ethereum" parse as .secp256k1, as the ledger treats them the same way; they are never sent.
To use Falcon-512, enable the add-on once at start-up:
import Activeledger
import ActiveledgerFalcon
ActiveledgerFalcon.enable()
let key = try keys.generateKey("me", type: .preferredPostQuantum) // Falcon-512
KeyType.preferredPostQuantum is Falcon-512 once the add-on is enabled and ML-DSA-65 otherwise, so the same code runs either way. Asking for .falcon512 without enabling the add-on throws ActiveledgerError.keyTypeUnavailable.
Save and load a key file. The format is the JavaScript SDK's, so files move between the two:
try keys.exportKey(key, to: "keys", createDir: true) // keys/<name>.json
let loaded = try keys.importKey("keys/identity.json")
Warning
The key file contains the private key. exportKey refuses to overwrite an existing file unless you pass overwrite: true. Keep the file out of version control.
importKey checks that the two halves belong together. A secp256k1 key whose public half is not a valid point, or does not match the private scalar, fails to load. So does an ML-DSA-65 pair where a test signature made with the private key does not verify under the public key. A key file with invented keys therefore no longer loads. PEM keys (RSA and older secp256k1 PEM) are not checked at import, and neither is Falcon-512 unless the add-on is enabled.
The public Key(name:type:key:identity:phrase:) initialiser does no checking and does not throw. If you build a Key by hand from stored strings, you get no consistency check; go through importKey or the KeyHandler generators when you can.
Public keys are also checked when a signature is verified. A secp256k1 public key that is off the curve, has a coordinate at or above the field prime, is the point at infinity, is all zero or uses the hybrid encoding is rejected, so verify returns false.
Why the post-quantum types are worth their size, and which to pick, is covered in Keys, identities and namespaces.
Seeds and recovery phrases¶
A seed or a BIP-39 phrase recreates the same identity in every Activeledger SDK.
let key = try keys.generateBip39Key("me")
print(key.phrase!) // 12 words: store them
let same = try keys.restoreBip39Key("me", phrase: key.phrase!)
let withPassphrase = try keys.restoreBip39Key("me", phrase: key.phrase!, passphrase: "extra words")
let fromSeed = try keys.generateKeyFromSeed("me", seed: [UInt8](repeating: 7, count: 32))
Each key type derives its own seed from the phrase, so one phrase can back a secp256k1, an ML-DSA-65 and a Falcon-512 identity at once. Pass type: to generateBip39Key and restoreBip39Key to choose.
| Type | Raw seed length for generateKeyFromSeed |
|---|---|
.secp256k1 |
32 bytes |
.mlDsa65 |
32 bytes |
.falcon512 |
48 bytes |
Warning
A seed of the wrong length is refused, not padded. A padded seed is a different identity. For secp256k1 the seed is the private scalar, so zero, or a value at or above the curve order, is also refused.
Phrases are checked against the word list and the checksum unless you pass validate: false. legacy: true recovers a phrase made by the old @activeledger/sdk-bip39 package; it works for secp256k1 only and is for recovery, not new keys. The Recovery enum exposes each step (validate, toSeed, deriveSeed, deriveBip32MasterKey, generateMnemonic) if a recovered identity is not the one you expected.
Sending transactions¶
The structure of a transaction, and what $i, $o and $r mean, is on Transactions. Here is how to build one in Swift. labelledTransaction builds a transaction with one input and signs it. The key must already be onboarded.
let tx = try ledger.transactions.labelledTransaction(
key: key,
namespace: "mynamespace",
contract: "mycontract",
inputLabel: "input",
stream: key.identity!,
inputData: .object([("message", .string("hello"))]),
entry: "update", // optional $entry
outputs: .object([("target-stream", .object([("amount", .integer(10))]))]))
let response = try await ledger.send(tx)
if !response.committed {
throw ActiveledgerError.ledger(response.errors.joined(separator: "; "))
}
response.umid // the transaction id
response.created // [StreamRef] of streams created
response.updated // [StreamRef] of streams updated
labelledTransaction also takes readonly: ($r) and selfsign:. inputData must be a .object. Swift needs labelled arguments in declaration order, so entry: goes before outputs:, then readonly:, then selfsign:. Code written with outputs: first will not compile.
committed is true only when the reply has a $summary object and its errors are absent or empty. errors reports a problem, rather than an empty list, when $summary is missing, is not an object, or has an errors that is not a list. So a {} reply or one without $summary is not committed, while commit: 0 with no errors is. committed is always errors.isEmpty.
send throws ActiveledgerError.http for a non-2xx status, and throws if the body is not a JSON object. A connection that is refused fails within about 5 seconds; a request that connects but gets no answer times out after 30 seconds.
JSON values are the JSONValue enum: .null, .bool, .integer(Int64), .number(Double), .string, .array and .object. An object is an ordered array of (String, JSONValue) pairs, because the ledger does not sort keys and the signature covers the exact bytes.
For several inputs or several signers, build the $tx body yourself and sign it once per key:
let tx = Transaction(tx: .object([
("$namespace", .string("ns")),
("$contract", .string("c")),
("$i", .object([(alice.identity!, .object([])), (bob.identity!, .object([]))])),
]))
try ledger.transactions.signTransaction(tx, key: alice)
try ledger.transactions.signTransaction(tx, key: bob)
let response = try await ledger.send(tx)
signTransaction stores the signature in $sigs under selfSignLabel if you pass one, otherwise the key's identity, otherwise its name. If you change the body after signing, sign again.
Reading state¶
The SDK has no read endpoint. A node's storage service listens only on the node's own host, so the SDK never talks to it. Read state the way Transactions describes: name the streams in readonly ($r) and have the contract return values with returnToRemote. They arrive in response.responses, an array of Any.
let tx = try ledger.transactions.labelledTransaction(
key: key, namespace: "mynamespace", contract: "mycontract",
inputLabel: "input", stream: key.identity!,
readonly: .object([("my-stream", .object([]))]))
let response = try await ledger.send(tx)
print(response.responses)
response.raw is the decoded JSON dictionary, for anything the typed accessors do not cover. response.total, vote and commit give the consensus totals; each is nil if the reply lacks it.
Events¶
The Swift SDK has no event subscription. It sends transactions and reads their responses; it does not listen to a node's event stream.
Signing elsewhere¶
To sign outside the process, say in a Secure Enclave or an HSM, you have two routes.
Sign the transaction bytes yourself. signedString is the exact string the ledger checks, and setSignature stores the result:
let tx = Transaction(tx: body)
let toSign = tx.signedString // sign this string elsewhere
tx.setSignature(base64Signature, for: key.identity!)
let response = try await ledger.send(tx)
Or replace the crypto for every handler. Implement the CryptoProvider protocol (generate, generateFromSeed, sign and verify) and pass it in: Activeledger("http://localhost:5260", crypto: myProvider), KeyHandler(crypto:), TransactionHandler(crypto:) and PayloadHandler(crypto:) all take one. A custom post-quantum scheme can be added with the PostQuantumScheme protocol and PostQuantum.register(_:for:).
PayloadHandler signs and verifies things that are not transactions, such as an order or a login challenge:
let payloads = PayloadHandler()
let order: JSONValue = .object([("pair", .string("VNR/USDT")), ("side", .string("sell"))])
let signature = try payloads.sign(order, key: key)
let ok = payloads.verify(order, signature: signature, publicKey: key.publicKey, type: .secp256k1)
verify returns false for malformed input rather than throwing, including a malformed RSA PEM key. sign with a malformed PEM private key throws ActiveledgerError.invalidArgument. On Linux, swift-crypto can abort the whole process on PEM that is framed correctly but is not a real key, so the SDK checks the PEM structure first (PEMGuard) and only then hands it on. try payloads.canonical(order) is the exact string signed. It depends on key order, so store it next to the signature.
Things that will bite you¶
Warning
A rejected transaction is HTTP 200. send only throws ActiveledgerError.http for a non-2xx status. Always check response.committed and read response.errors. A reply with no $summary is not treated as success.
Warning
Signature errors are vague. A wrong type string, a key of the wrong length or one stray byte in the signed text all come back as 1220 Signature Incorrect. Compare tx.signedString with what you meant to sign.
- Key order is part of the signature. Build
$txwithJSONValue.objectand do not reorder it. Swift'sJSONEncoderdoes not matchJSON.stringify, so the SDK has its owncanonicalJSON. It refusesNaN, infinities and integers that a JavaScript number cannot hold, rather than sign something you did not write. - Onboard before building transactions.
labelledTransactionthrowsinvalidArgumentifkey.identityis nil. - Onboarding keys on the key name. The onboarding transaction uses
key.nameas its$ilabel, andonboardthrowsActiveledgerError.onboardif the reply is not committed or the ledger creates no stream. - Onboarding does not check
committed.onboardonly requires that a stream was created. It setskey.identityfrom the first created stream and returns the response without checkingcommitted, so a reply that creates a stream but also lists errors is accepted. This is a known gap. Checkcommittedon the responseonboardreturns if that matters to you. - The key type is always sent. The ledger treats a missing
typeasrsa; the SDK never omits it, so do the same if you build an envelope by hand. - Falcon needs enabling. Call
ActiveledgerFalcon.enable()once, or.falcon512throwskeyTypeUnavailable. - Falcon signatures vary in length. Never assume a fixed width.
Keyis a class.onboardsetskey.identityon the object you pass.- Signing is not constant across schemes. secp256k1 signatures are deterministic; post-quantum signatures are hedged, so two signatures over one message differ and both verify.
Testing and further reading¶
swift test
The unit tests run offline against the cross-language vectors in the SDK's vectors/ directory. LiveNetworkTests runs the full lifecycle against real nodes and skips unless AL_NODES is set; see the SDK README for how to start a local network and set it. swift run Example [nodeURL] [keyType] runs a small end-to-end demo against a node.
The source, README and issue tracker are at github.com/activeledger/SDK-Swift.