The TypeScript surface has two parts:
  1. SigilRpcClient — a thin JSON-RPC wrapper that powers the official wallet, explorer, and faucet. Source: sigil-web/lib/rpc/client.ts.
  2. Signer — Ed25519-over-BLAKE3 with the canonical sigil/transaction/v1\n domain tag, matching sigil-core byte-for-byte. Source: sigil-web/lib/crypto/signer.ts.
Both are shipped in-repo today. A standalone npm package will be published once the SDK API stabilises.

Install

Until the standalone package is published, vendor the two source files (rpc/client.ts, crypto/signer.ts, crypto/canonical.ts) plus their @noble peers into your project.
bun add @noble/ed25519@^2 @noble/hashes@^1 @noble/ciphers@^1
# or
npm install @noble/ed25519 @noble/hashes @noble/ciphers
Node 20+ or Bun 1.0+ is required. The signer uses crypto/hashes synchronous mode (which the SDK enables at module load) so you can call signTransaction from any sync context.

Configuration

SigilRpcClient takes a NetworkConfig:
import { SigilRpcClient } from './rpc/client';

const client = new SigilRpcClient({
  id: 'testnet',
  chainId: 'sigil-testnet-1',
  rpcUrl: 'https://testnet-rpc.sigil.ml/rpc',
  currencySymbol: 'SIGIL',
});
You can swap networks on the same instance with client.setNetwork(...). The official wallet does this when the user changes the top-bar switcher. Recommended environment variables:
export SIGIL_RPC_URL=https://testnet-rpc.sigil.ml/rpc
export SIGIL_CHAIN_ID=sigil-testnet-1
export SIGIL_SIGNING_KEY_HEX=...   # 64 hex chars
export SIGIL_SENDER_DID=did:oas:sigil:agent:...

Identity

import * as ed25519 from '@noble/ed25519';
import { blake3 } from '@noble/hashes/blake3';
import { sha512 } from '@noble/hashes/sha512';
import { bytesToHex } from '@noble/ciphers/utils';

// Required once per process; @noble/ed25519 v2 needs a sync SHA-512.
ed25519.etc.sha512Sync = (...m) => {
  const h = sha512.create();
  for (const part of m) h.update(part);
  return h.digest();
};

export interface SigilIdentity {
  did: string;
  publicKeyHex: string;
  privateKey: Uint8Array;
}

export async function newAgent(): Promise<SigilIdentity> {
  const privateKey = ed25519.utils.randomPrivateKey();
  const publicKey = await ed25519.getPublicKey(privateKey);
  const address = blake3(publicKey);
  return {
    did: `did:oas:sigil:agent:${bytesToHex(address)}`,
    publicKeyHex: bytesToHex(publicKey),
    privateKey,
  };
}

Loading and rotating keys

A signing key is 32 bytes; persist it encrypted at rest. The wallet stores keys inside an idb (IndexedDB) vault sealed with AES-GCM derived from a user passphrase — see sigil-web/lib/crypto/vault.ts for the reference implementation.
import { hexToBytes } from '@noble/ciphers/utils';

export async function loadAgent(hexSeed: string): Promise<SigilIdentity> {
  const privateKey = hexToBytes(hexSeed);
  const publicKey = await ed25519.getPublicKey(privateKey);
  const address = blake3(publicKey);
  return {
    did: `did:oas:sigil:agent:${bytesToHex(address)}`,
    publicKeyHex: bytesToHex(publicKey),
    privateKey,
  };
}
DIDs are bound to a public key at registration. To rotate, mint a fresh identity and migrate balances via a single Transfer; in-place rotation is not exposed on testnet today.

Reading chain state

// Balance and nonce.
const { did, balance, nonce } = await client.getBalance('did:oas:sigil:agent:...');
console.log(`${did} has ${balance} micro-MINT (nonce ${nonce})`);

// Sync status.
const sync = await client.getSyncStatus();
if (!sync.synced) {
  console.warn(`node is ${sync.highest_known - sync.current_height} blocks behind`);
}

// Identity document.
const id = await client.getIdentity('did:oas:sigil:agent:...');
if (!id.registered) {
  console.log('DID has not been registered on-chain yet');
}
The full method index — including sigil_getBlock, sigil_getTransaction, sigil_listValidators, and all primitive-specific queries — is in API reference → JSON-RPC.

Calling unwrapped methods

SigilRpcClient wraps the methods used by the wallet. For anything not yet wrapped (NFT queries, DEX quotes, labor offers, …) call the underlying transport directly:
async function rpc<T>(rpcUrl: string, method: string, params: unknown = {}): Promise<T> {
  const res = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json() as { result?: T; error?: { code: number; message: string } };
  if (data.error) throw new Error(`RPC ${data.error.code}: ${data.error.message}`);
  if (data.result === undefined) throw new Error('missing result');
  return data.result;
}

const pool = await rpc('https://testnet-rpc.sigil.ml/rpc',
  'sigil_getDexPool', { pool_id: 'pool-sigil-usdc' });

Signing and submitting transactions

The signing transcript is 'sigil/transaction/v1\n' + JSON.stringify(transaction), hashed with BLAKE3 and signed with Ed25519. The transaction shape mirrors sigil_core::Transaction exactly — tx_type is the enum tag plus payload.
import * as ed25519 from '@noble/ed25519';
import { blake3 } from '@noble/hashes/blake3';
import { bytesToHex } from '@noble/ciphers/utils';

const TX_DOMAIN_TAG = 'sigil/transaction/v1\n';

export interface TransferTxType {
  Transfer: {
    recipient_did: string;
    amount: number;
    memo: string | null;
  };
}

export interface UnsignedTransaction {
  chain_id: string;
  sender_did: string;
  nonce: number;
  gas_limit: number;
  gas_price: number;
  tx_type: TransferTxType | Record<string, unknown>;
  timestamp: string;
}

export interface SignedTransaction {
  tx: UnsignedTransaction;
  signature: string;
  signer_public_key: string;
}

export async function signTransaction(
  tx: UnsignedTransaction,
  privateKey: Uint8Array,
  publicKeyHex: string,
): Promise<SignedTransaction> {
  const transcript = TX_DOMAIN_TAG + JSON.stringify(tx);
  const digest = blake3(new TextEncoder().encode(transcript));
  const signature = await ed25519.sign(digest, privateKey);
  return {
    tx,
    signature: bytesToHex(signature),
    signer_public_key: publicKeyHex,
  };
}

Submitting

async function submit(
  client: SigilRpcClient,
  signed: SignedTransaction,
): Promise<string> {
  const { tx_hash } = await client.sendTransaction(signed);
  return tx_hash;
}

Waiting for finality

async function waitReceipt(
  client: SigilRpcClient,
  txHash: string,
  timeoutMs = 30_000,
): Promise<{ status: 'final' | 'failed'; block_height: number; gas_used: number | null }> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const r = await client.getTransactionReceipt(txHash);
    if (r && r.status !== 'pending') {
      return r as { status: 'final' | 'failed'; block_height: number; gas_used: number | null };
    }
    await new Promise((resolve) => setTimeout(resolve, 1_500));
  }
  throw new Error(`receipt for ${txHash} did not finalise in ${timeoutMs}ms`);
}

Subscribing to events

WebSocket subscriptions are on the roadmap. Until they ship, poll sigil_getLatestBlock:
async function* finalizedBlocks(client: SigilRpcClient): AsyncGenerator<unknown> {
  let last = 0;
  while (true) {
    const block = await rpc<{ height: number }>(
      client.getNetwork().rpcUrl!,
      'sigil_getLatestBlock',
      {},
    );
    if (block.height > last) {
      last = block.height;
      yield block;
    }
    await new Promise((resolve) => setTimeout(resolve, 1_500));
  }
}

for await (const block of finalizedBlocks(client)) {
  console.log('new block:', block);
}
For the strongest finality guarantee, query tower_getFinalityStatus and use only blocks at-or-below the returned finalized_height.

Error handling

SigilRpcClient throws RpcError with the chain’s JSON-RPC error code attached. Catch and inspect err.code to discriminate.
import { RpcError } from './rpc/client';

try {
  await client.sendTransaction(signed);
} catch (err: unknown) {
  if (err instanceof RpcError) {
    switch (err.code) {
      case -32601: // method not found — chain upgrade may be required
        console.error('this RPC node does not expose the method');
        break;
      case -32002: // mempool full
        console.warn('mempool full, retrying in 500ms');
        break;
      default:
        console.error('rpc failed:', err.code, err.message);
    }
  } else {
    throw err;
  }
}
Common codes:
CodeMeaning
-32600Invalid request (malformed JSON-RPC envelope)
-32601Method not found
-32602Invalid params (typo, missing field, wrong type)
-32603Internal error (transient, retryable)
-32000Network unreachable (synthesised client-side)
-32002Mempool full / rate-limited
See API reference → Errors for the complete table.

Worked example: send a payment with retry

import * as ed25519 from '@noble/ed25519';
import { blake3 } from '@noble/hashes/blake3';
import { sha512 } from '@noble/hashes/sha512';
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
import { SigilRpcClient, RpcError } from './rpc/client';

ed25519.etc.sha512Sync = (...m) => {
  const h = sha512.create();
  for (const part of m) h.update(part);
  return h.digest();
};

const TX_DOMAIN_TAG = 'sigil/transaction/v1\n';

interface UnsignedTransaction {
  chain_id: string;
  sender_did: string;
  nonce: number;
  gas_limit: number;
  gas_price: number;
  tx_type: Record<string, unknown>;
  timestamp: string;
}

async function signAndPack(
  tx: UnsignedTransaction,
  privateKey: Uint8Array,
  publicKeyHex: string,
): Promise<{ tx: UnsignedTransaction; signature: string; signer_public_key: string }> {
  const transcript = TX_DOMAIN_TAG + JSON.stringify(tx);
  const digest = blake3(new TextEncoder().encode(transcript));
  const signature = await ed25519.sign(digest, privateKey);
  return { tx, signature: bytesToHex(signature), signer_public_key: publicKeyHex };
}

export async function sendPaymentWithRetry(
  client: SigilRpcClient,
  privateKey: Uint8Array,
  publicKey: Uint8Array,
  senderDid: string,
  recipientDid: string,
  amountBaseUnits: number,
): Promise<string> {
  const { nonce } = await client.getBalance(senderDid);

  const tx: UnsignedTransaction = {
    chain_id: client.getNetwork().chainId,
    sender_did: senderDid,
    nonce,
    gas_limit: 200_000,
    gas_price: 1,
    tx_type: {
      Transfer: {
        recipient_did: recipientDid,
        amount: amountBaseUnits,
        memo: null,
      },
    },
    timestamp: new Date().toISOString(),
  };
  const signed = await signAndPack(tx, privateKey, bytesToHex(publicKey));

  let delay = 250;
  for (let attempt = 0; attempt < 5; attempt += 1) {
    try {
      const { tx_hash } = await client.sendTransaction(signed);
      return tx_hash;
    } catch (err: unknown) {
      if (err instanceof RpcError && (err.code === -32002 || err.code === -32603) && attempt < 4) {
        await new Promise((resolve) => setTimeout(resolve, delay));
        delay *= 2;
        continue;
      }
      throw err;
    }
  }
  throw new Error('unreachable');
}

if (import.meta.main) {
  const rpcUrl = process.env.SIGIL_RPC_URL ?? 'https://testnet-rpc.sigil.ml/rpc';
  const chainId = process.env.SIGIL_CHAIN_ID ?? 'sigil-testnet-1';
  const senderDid = process.env.SIGIL_SENDER_DID!;
  const recipientDid = process.env.RECIPIENT_DID!;
  const amount = Number(process.env.AMOUNT_BASE_UNITS ?? '0');
  if (!amount) throw new Error('set AMOUNT_BASE_UNITS');

  const privateKey = hexToBytes(process.env.SIGIL_SIGNING_KEY_HEX!);
  const publicKey = await ed25519.getPublicKey(privateKey);
  const client = new SigilRpcClient({
    id: 'env',
    chainId,
    rpcUrl,
    currencySymbol: 'SIGIL',
  });
  const txHash = await sendPaymentWithRetry(
    client,
    privateKey,
    publicKey,
    senderDid,
    recipientDid,
    amount,
  );
  console.log('submitted:', txHash);
}

Reference