Skip to content

Getting started

The smallest real thing you can build is a one-validator chain in a single process, driven through the SDK. It’s genuinely end-to-end — real Ed25519 keys, real signature verification, real consensus (with a quorum of 1) — and it runs in Node ≥ 24 or any modern browser. This page is also the @johnhenry/raijin-sdk (0.1.0) API reference. The package exports Wallet, RaijinClient, the types ClientTransport, BuildTxOptions, GenerateOptions and ImportOptions, and re-exports the wire codec from core (encodeMessage, decodeMessage, WireFormatError, WireValue).

Terminal window
npm install @johnhenry/raijin-sdk @johnhenry/raijin-validator @johnhenry/raijin-core

Latest on npm: @johnhenry/[email protected], @johnhenry/[email protected], @johnhenry/[email protected].

Wallet wraps Web Crypto Ed25519. Verification isn’t built into the wallet — you inject a SignatureVerifier into the node, and raijin-core ships the real one:

import { Wallet } from '@johnhenry/raijin-sdk'
import { ed25519Verifier } from '@johnhenry/raijin-core'
const validatorWallet = await Wallet.generate()
const userWallet = await Wallet.generate() // publicKey (32 bytes) is the address
const merchantPublicKey = (await Wallet.generate()).publicKey

ed25519Verifier is { verify(publicKey, signature, message) } — WebCrypto argument order, key first, which changed in raijin-core 0.1.0 (it used to be (message, signature, publicKey)). If you write your own verifier, follow the same order. verifyEd25519 throws a TypeError when the public key is not 32 bytes, which is how a swapped call site announces itself. See Core.

Describe genesis as data: chain id, the ordered validator set, and the initial accounts. ValidatorNode.create derives block 0 from it, seeds the store, and resolves once genesis is applied:

import { InMemoryStateStore } from '@johnhenry/raijin-core'
import { ValidatorNode } from '@johnhenry/raijin-validator'
const node = await ValidatorNode.create({
chainId: 1n, // required — no default
identity: {
publicKey: validatorWallet.publicKey,
sign: (msg) => validatorWallet.sign(msg),
verify: ed25519Verifier,
},
transport: { broadcast() {}, send() {}, onMessage() {} }, // single node: no peers
timer: { set: (ms, cb) => setTimeout(cb, ms), clear: clearTimeout },
store: new InMemoryStateStore(),
genesis: {
chainId: 1n, // must equal the node's chainId
validators: [validatorWallet.publicKey], // must include self, or nothing finalizes
accounts: [{ address: userWallet.publicKey, balance: 1_000n }],
},
blockTime: 500,
})
node.start()

With one validator, quorum is 1: every blockTime tick the node drains its mempool, proposes, and finalizes its own block immediately. (You can also fund accounts by writing to the store at accountKey(address) before starting, and pass validators directly instead of genesis; see Core and Validator.)

RaijinClient talks through a ClientTransport you implement — that’s the network boundary. In-process it’s a few lines; over HTTP or WebRTC it’s the same four methods:

import { RaijinClient } from '@johnhenry/raijin-sdk'
import { hash, encodeTxSigned, equal } from '@johnhenry/raijin-core'
const transport = {
async submitTransaction(tx) {
const want = await hash(encodeTxSigned(tx)) // receipts carry hash(encodeTxSigned(tx))
const receipt = new Promise((resolve) =>
node.onBlockFinalized((_b, receipts) => {
const r = receipts.find((r) => equal(r.txHash, want))
if (r) resolve(r)
}))
await node.submitTransaction(tx)
return receipt
},
getAccount: (addr) => node.stateMachine.getAccount(addr),
async getBlock(n) {
return node.latestBlock?.header.number === n ? node.latestBlock : null
},
onBlock(handler) { node.onBlockFinalized((b) => handler(b)); return () => {} },
}
const client = new RaijinClient(transport)
const account = await client.getAccount(userWallet.publicKey)
const tx = await userWallet.buildTx({
to: merchantPublicKey, // Uint8Array(32) — or null for system ops
value: 250n,
nonce: account.nonce, // nonce management is yours; wrong nonce ⇒ revert receipt
chainId: 1n, // required — part of the signed bytes
})
const receipt = await client.submitTransaction(tx)
receipt.status // 'success' — or 'revert' with receipt.revertReason

node.submitTransaction() now validates on the way in: a bad signature, a duplicate sender+nonce, or a pool with nothing cheaper to evict makes it throw instead of producing a revert receipt a block later (a wrong nonce is still a revert receipt). In this in-process transport that surfaces as a rejected client.submitTransaction(); a remote transport decides how to report it.

The complete runnable script — including subscribing to blocks and demonstrating that a post-signing mutation produces an 'invalid signature' revert — is examples/07-sdk-end-to-end.mjs in the repo (npm run example:07).

Member Signature Notes
Wallet.generate(opts?) ({ extractable? }) → Promise<Wallet> New Ed25519 keypair. Throws where Web Crypto lacks Ed25519 (pre-20 Node, old browsers). Non-extractable by default; pass { extractable: true } only if the key has to be exported.
Wallet.fromKey(pkcs8, opts?) (Uint8Array, { extractable? }) → Promise<Wallet> Import a PKCS8 private key; public key is derived. Non-extractable by default, same as generate() — fromKey is the persistence path, so it is the one most likely to be handed to code that never asked for an exportable key.
wallet.publicKey Uint8Array 32 raw bytes — this is the address everywhere in raijin.
wallet.sign(message) → Promise<Uint8Array> 64-byte deterministic Ed25519 signature.
wallet.buildTx(opts) (BuildTxOptions) → Promise<Transaction> Builds and signs. BuildTxOptions = { to, value, nonce, data?, chainId }; chainId is required (no default), data defaults to empty.
wallet.exportPrivateKey() → Promise<Uint8Array> PKCS8; round-trips through fromKey. Throws unless the wallet was created with { extractable: true }.

Three facts worth engraving:

  • The signature covers encodeTx(tx) — every field except the signature — so any post-buildTx mutation invalidates it.
  • hash(encodeTxSigned(tx)) is the txHash receipts carry, the same id the block’s txRoot leaves and the mempool’s dedup key use, which is how you correlate submissions to results. Signature verification still runs over the unsigned encodeTx(tx) bytes, because that is what was signed.
  • chainId is required because it is the only field separating one deployment from another: a default would give every chain that never chose one the same id, and a signed transfer on either would be valid on the other. It is part of the signed bytes; the reference state machine does not additionally check it against a configured chain.

Note also that the state machine reads tx.data[0] as the transaction type; empty data means plain transfer. Private keys are non-extractable unless you ask, on both generate() and fromKey() — in a browser that is the strongest guarantee WebCrypto offers.

RaijinClient — typed pass-through to your transport

Section titled “RaijinClient — typed pass-through to your transport”

submitTransaction(tx), getAccount(address) (unknown addresses return zero accounts, not errors), getBlock(number) (null if missing), and subscribe(handler) which returns its own unsubscribe function. All reliability semantics — timeouts, retries, which validator you’re actually talking to — belong to your ClientTransport, not the client. A transport that talks to a remote node can send encodeMessage(tx) (re-exported from raijin-core’s wire codec) instead of inventing a JSON replacer.

Multiple validators means a real NetworkTransport and identical validators arrays on every node — read Consensus for the quorum math (nothing is Byzantine-tolerant below 4) and Architecture for the full block lifecycle before you scale past one.