JavaScript / TypeScript SDK¶
The JavaScript SDK builds, signs and sends transactions, and makes and recovers keys, from Node.js, browsers and React Native. It ships as three npm packages: @activeledger/sdk-node, @activeledger/sdk-web and @activeledger/sdk-core, which holds the code the other two share. This page covers version 2.6.0 plus the changes merged since, which are unreleased: the committed rule and isCommitted, importKey validation, and the web fromHex and validate: false fixes. Until they are released, an installed 2.6.0 behaves as before. It is written in TypeScript and ships type definitions. It supports secp256k1 and the post-quantum types ml-dsa-65 and falcon-512; it cannot create rsa keys.
The post-quantum types need Node 20.19 or later. The packages declare no engines field, so nothing stops an older Node installing them; the SDK's CI tests Node 20.19, 22 and 24 only. Browsers and React Native have no Node requirement. The post-quantum types also need Activeledger 4.7.0 or later on the nodes; an older node rejects them at onboarding. This guide covers Activeledger 5.x, which is well past that.
The SDK sends transactions and keeps keys. It does not read streams or listen for events; see Reading state and Events.
Install¶
Pick the package for where your code runs. Both have the same API, apart from key file storage (see Keys), and a key made by one works in the other.
| Where | Package | Signing |
|---|---|---|
| Node.js | @activeledger/sdk-node |
node:crypto for secp256k1; @noble/post-quantum for the post-quantum types |
| Browser | @activeledger/sdk-web |
@noble/curves and @noble/post-quantum, pure JavaScript |
| React Native | @activeledger/sdk-web |
as for the browser |
npm install @activeledger/sdk-node
npm install @activeledger/sdk-web
Do not install @activeledger/sdk-core yourself. It comes in as a dependency, and everything in it is re-exported from the other two packages. sdk-web is an ES module package ("type": "module").
Quick start¶
This onboards a new identity and sends one transaction with it. It needs a running node and a contract; see Identities and Transactions for what these mean.
import { Connection, KeyHandler, TransactionHandler, isCommitted } from "@activeledger/sdk-node";
// or "@activeledger/sdk-web": the same names
const connection = new Connection("http", "localhost", 5260);
const keys = new KeyHandler();
const key = await keys.generateKey("mykey");
await keys.onboardKey(key, connection); // key.identity is now the new stream id
const tx = await new TransactionHandler().labelledTransaction({
key,
namespace: "myorg",
contract: "<contract stream id>",
inputLabel: "sender",
inputData: { amount: 1 },
stream: key.identity!,
});
const response = await connection.sendTransaction(tx);
const errors = response.$summary.errors; // the ledger's reasons, if it refused
if (!isCommitted(response)) {
throw new Error(`Not committed: ${errors?.join("; ")}`);
}
console.log(response.$umid);
new Connection(protocol, address, port) also takes one object, { protocol, address, portNumber }. The default node port is 5260.
Keys¶
generateKey(name, compressed?, type?) makes a key and returns a plain object, an IKey:
| Field | What it is |
|---|---|
name |
The label you gave it. Used as the key in $i and $sigs until the key has an identity. |
type |
KeyType.EllipticCurve (secp256k1), KeyType.MLDSA65 (ml-dsa-65) or KeyType.Falcon512 (falcon-512). |
key.pub.pkcs8pem, key.prv.pkcs8pem |
The public and private key. The field name is historical: secp256k1 keys are 0x hex, post-quantum keys are base64. |
identity |
The stream id. Empty until onboardKey succeeds. |
KeyType.EllipticCurve is the default. compressed only matters for secp256k1 and is ignored for the others.
import { KeyHandler, KeyType } from "@activeledger/sdk-node";
const key = await new KeyHandler().generateKey("quantum-safe", false, KeyType.MLDSA65);
POST_QUANTUM_KEY_TYPES is exported if you need to ask whether a type is post-quantum. For which type to choose and why, see Identities.
onboardKey(key, connection) registers the public key with the onboard contract and sets key.identity. It sets it only when the reply is committed (see Sending transactions) and names the new stream. Otherwise the promise rejects, with the ledger's reasons when it gave any, and key.identity stays unset.
sdk-node can save and load keys:
await keys.exportKey(key, { location: "./keys", createDir: true }); // writes ./keys/mykey.json
const again = await keys.importKey("./keys/mykey.json");
exportKey also accepts overwrite and name, and refuses to overwrite an existing file unless overwrite is true. sdk-web has neither method; storing a key is up to your app.
importKey treats the file as untrusted. It checks the type, the key encodings and lengths, and that the private and public keys belong together, and rejects with a clear error if not. For secp256k1 it derives the public key from the private scalar and compares; for ml-dsa-65 and falcon-512 it signs and verifies a test message. A mismatched pair, or a public key that is not a point on the curve, is refused here and not at first use. A bad key file rejects with, for example, Invalid secp256k1 key: the private and public keys do not belong together.
Warning
exportKey writes the whole IKey as plain JSON, private key included. It does not encrypt the file or set its permissions. Protect it yourself.
Seeds and recovery phrases¶
A 12-word BIP-39 phrase can recreate a key on any machine. This is available in both packages.
const key = await keys.generateBIP39Key("mykey"); // key.phrase holds the 12 words
const same = await keys.restoreBIP39Key("mykey", key.phrase);
Both take an options object as the last argument:
| Option | Meaning |
|---|---|
type |
The key type to derive. One phrase can back a secp256k1, an ml-dsa-65 and a falcon-512 identity; each gets its own seed. |
passphrase |
An optional BIP-39 passphrase. The same words with a different passphrase are a different key. |
compressed |
As for generateKey. |
validate |
On by default. The phrase must be 12, 15, 18, 21 or 24 words, from the English list, with a correct checksum. Pass false only to derive from something that is not a mnemonic. It gives the same key in sdk-node and sdk-web. |
legacy |
Reproduces the old @activeledger/sdk-bip39 package (SHA256(phrase) as the private key). For recovering an old phrase only; refused for the post-quantum types and not validated. |
A phrase gives the same key in sdk-node and sdk-web. For secp256k1 the SDK takes the BIP-32 master key from the phrase's seed and goes no further; it does not derive child keys. For the post-quantum types it uses HKDF-SHA512 with a different label for each type.
Warning
Other tools derive a key from a phrase differently. The same twelve words can give a different identity in each, with no error. Recover a phrase with the software that made it.
To start from a seed rather than a phrase, use generateKeyFromSeed(name, seed, compressed?, type?). The seed is the algorithm's own: 32 bytes for secp256k1 and ml-dsa-65, 48 for falcon-512. A wrong length is refused, not padded. A secp256k1 seed of zero, or one not below the curve order, is refused too.
const seed = new Uint8Array(32); // fill with 32 bytes from a secure source
const key = await keys.generateKeyFromSeed("mykey", seed, true, KeyType.EllipticCurve);
If you need the steps on their own, recovery is exported from both packages:
import { recovery, KeyType } from "@activeledger/sdk-node";
const bip39Seed = recovery.toSeed(phrase); // 64 bytes, validates the phrase
const seed = recovery.deriveSeed(KeyType.Falcon512, bip39Seed); // 48 bytes
recovery.validate(phrase) checks a phrase and returns it normalised, or throws with the reason.
Sending transactions¶
TransactionHandler.labelledTransaction builds and signs a transaction whose input is a labelled stream. The key must have an identity, or it rejects with Key must have an identity.
| Option | $tx field |
Notes |
|---|---|---|
key |
Signs the transaction. | |
namespace, contract |
$namespace, $contract |
|
inputLabel, inputData, stream |
$i |
Becomes $i[inputLabel] = { ...inputData, $stream: stream }. |
entry |
$entry |
Optional. |
outputs |
$o |
Optional. |
readonly |
$r |
Optional. |
selfsign |
$selfsign |
Optional. Also puts the key's public key and type into the input, and keys $sigs by inputLabel. |
The signature goes under the key's identity in $sigs. There is also a positional form, labelledTransaction(key, namespace, contract, inputLabel, inputData, stream, entry?, outputs?, readonly?, selfsign?).
connection.sendTransaction(tx) posts it and resolves with the node's reply as an ILedgerResponse: $umid, $summary, $streams and optionally $responses. Transactions explains each field.
Warning
A refused transaction can still resolve. The node may answer HTTP 200 and put the failure in the reply. Do not treat a resolved promise as success. Pass the reply to isCommitted(response), which is exported from both packages.
A reply counts as committed only if the HTTP status is 2xx, the body is a JSON object, it has a $summary object, and $summary.errors is absent or empty. commit is not consulted: the ledger reports a rejection in errors. ILedgerResponse declares $summary.errors?: string[], so no cast is needed.
| Reply | sendTransaction |
isCommitted |
|---|---|---|
2xx, $summary with no errors |
resolves | true |
2xx, $summary.errors not empty |
resolves, with the errors in $summary |
false |
2xx, body {}, an HTML page or empty |
rejects: Unexpected ledger response (no $summary) |
n/a |
| non-2xx, or no connection | rejects (the HTTP library's error) | n/a |
isCommitted itself never throws; given anything that is not a committed reply it returns false. onboardKey rejects when the reply is not committed or names no new stream.
For a transaction the helper cannot shape, build the object yourself and sign it with signTransaction. This is how to add $expire, which labelledTransaction has no option for (see Transactions for why you want it on anything you might retry):
const handler = new TransactionHandler();
const tx = await handler.signTransaction(
{
$tx: {
$namespace: "myorg",
$contract: "<contract stream id>",
$i: { [key.identity!]: { amount: 1 } },
$expire: "2030-01-01T00:00:00Z",
} as any,
$sigs: {},
},
key,
);
const response = await handler.sendTransaction(tx, connection);
signTransaction signs JSON.stringify(tx.$tx) and stores the signature in $sigs under the key's identity (its name if it has none). Nothing may change $tx afterwards.
buildOnboardKeyTx(key, { contract?, namespace? }) builds the onboarding transaction without sending it, for when you onboard through your own contract. The options apply to that call only.
Reading state¶
The SDK has no calls for reading streams. Connection has one method, sendTransaction. To read, post to the node's /a/stream endpoint with any HTTP client; see the HTTP endpoints.
const res = await fetch("http://localhost:5260/a/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ $streams: [key.identity] }),
});
const documents = await res.json();
Events¶
The SDK has LedgerEvents, but it is deprecated and will go in the next major version. It subscribes to ActiveCore, which is itself deprecated and no longer serves events. A node serves contract events from its storage service, which only listens on the node's own host and must not be exposed. A browser or phone has nothing it should connect to. Run a small server-sent events listener on the node's host and relay what your application needs; see the HTTP endpoints.
Signing elsewhere¶
PayloadHandler signs and verifies anything that is not a transaction: an order, an attestation, a login challenge. It needs no connection and no identity.
import { KeyHandler, PayloadHandler, KeyType } from "@activeledger/sdk-node";
const payloads = new PayloadHandler();
const key = await new KeyHandler().generateKey("maker", false, KeyType.MLDSA65);
const order = { pair: "VNR/USDT", side: "sell", amount: "100" };
const signature = payloads.sign(order, key); // base64
// Elsewhere, holding only the public key and its type:
payloads.verify(order, signature, key.key.pub.pkcs8pem, key.type); // true or false
verify never throws; a bad signature, a wrong key or a malformed input all give false. A string is signed as it is; anything else is signed as its JSON.stringify. payloads.canonical(value) returns that exact string.
TransactionHandler.signTransaction(text, key) with a string instead of an object returns just the base64 signature, for when you assemble the transaction elsewhere. Sign the exact $tx text you send.
Things that will bite you¶
- A key's type cannot change. It is recorded at onboarding. To move to another type, onboard a new identity.
JSON.stringifyis key-order sensitive. Storepayloads.canonical(value)beside a signature and verify against that. If something rebuilds the object field by field before you verify (a mapper, an ORM, a defaulter), the signature fails and it looks like a bad key.- The transaction must reach the node as it was signed.
signTransactionsignsJSON.stringify($tx). Do not change$tx, including key order, between signing and sending. - Post-quantum signatures are large. ML-DSA-65 is 3309 bytes. Falcon-512 is about 650 bytes, and the length varies, so do not size a buffer or a column to a fixed width.
- A recovery phrase is the identity. Anyone with the words can sign as you, and a mistyped phrase is refused by the checksum rather than giving a wrong key unless you turn
validateoff. - A resolved
sendTransactionis not a commit. CheckisCommitted(response)every time. - Resending runs it twice unless the transaction has
$expire. See Transactions. - React Native and browsers need a random source.
sdk-webcalls the globalcrypto.getRandomValuesfor post-quantum keys and signatures. A browser has it. React Native does not by default; install a polyfill for it before generating or signing. sdk-webis ESM only, and plain-Node use of the post-quantum types depends on Node being able torequire()an ES module, which is why Node 20.19 is the floor.
Testing and further reading¶
The source, issues and the cross-language test vectors are at github.com/activeledger/SDK-JS. The packages are on npm: @activeledger/sdk-node, @activeledger/sdk-web and @activeledger/sdk-core.
To run the SDK's own tests, clone the repository and run npm install then npm test. That builds all three packages and runs the unit tests, the cross-package signature checks and the vector checks. The upstream CI runs it on Node 20.19, 22 and 24.