Quickstart: TypeScript + WASM

Sign and send your first Lighter order from TypeScript using the official WASM signer from lighter-go. Works in Node.js and the browser.

Lighter transactions are signed with a custom scheme (not EIP-712). The reference signer is written in Go and compiled to WebAssembly, so you can use it from any JavaScript runtime.

By the end of this page you will have one runnable script that verifies your API key, places a limit order on mainnet from Node.js and cancels it. Using Python instead? See Get Started.

Prerequisites

1. Build the signer

git clone https://github.com/elliottech/lighter-go
cd lighter-go
GOOS=js GOARCH=wasm go build -trimpath -o ./build/lighter-signer.wasm ./wasm/

# Go's JS glue file, from your Go install
SRC="$(go env GOROOT)/lib/wasm/wasm_exec.js"
[ -f "$SRC" ] || SRC="$(go env GOROOT)/misc/wasm/wasm_exec.js"
cp "$SRC" ./build/wasm_exec.js
📘

Build both files together

wasm_exec.js must come from the same Go version that built the .wasm. Rebuild both together.

2. Set up the project

mkdir lighter-quickstart && cd lighter-quickstart
cp ../lighter-go/build/lighter-signer.wasm ../lighter-go/build/wasm_exec.js .
echo '{ "type": "module" }' > package.json

3. The script

Save as quickstart.ts. It verifies your API key, places a buy order far below the market (so it won't fill), then cancels it.

import fs from "node:fs";
import "./wasm_exec.js"; // defines globalThis.Go

// ---------- config ----------
const BASE_URL = process.env.LIGHTER_URL ?? "https://mainnet.zklighter.elliot.ai";
const CHAIN_ID = Number(process.env.LIGHTER_CHAIN_ID ?? 304); // 300 for testnet
const ACCOUNT_INDEX = Number(process.env.LIGHTER_ACCOUNT_INDEX);
const API_KEY_INDEX = Number(process.env.LIGHTER_API_KEY_INDEX);
const API_PRIVATE_KEY = process.env.LIGHTER_API_PRIVATE_KEY!;

// ---------- load the signer ----------
const go = new (globalThis as any).Go();
const { instance } = await WebAssembly.instantiate(
  fs.readFileSync("./lighter-signer.wasm"),
  go.importObject,
);
go.run(instance); // do not await: the Go runtime stays alive
const lighter = globalThis as any;

function check<T extends { error?: string }>(res: T): T {
  if (res?.error) throw new Error(res.error);
  return res;
}

// ---------- HTTP helpers (all network calls go through JS fetch) ----------
async function get(path: string, headers: Record<string, string> = {}) {
  return fetch(`${BASE_URL}${path}`, { headers }).then(r => r.json());
}

async function sendTx(tx: { txType: number; txInfo: string }) {
  const r = await fetch(`${BASE_URL}/api/v1/sendTx`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ tx_type: String(tx.txType), tx_info: tx.txInfo }),
  }).then(r => r.json());
  if (r.code !== 200) throw new Error(`sendTx ${r.code}: ${r.message}`);
  return r;
}

async function nextNonce(): Promise<number> {
  const r = await get(`/api/v1/nextNonce?account_index=${ACCOUNT_INDEX}&api_key_index=${API_KEY_INDEX}`);
  return r.nonce;
}

// ---------- 1. create the signer client (no network) ----------
check(lighter.CreateClient("", API_PRIVATE_KEY, CHAIN_ID, API_KEY_INDEX, ACCOUNT_INDEX));

// ---------- 2. verify the key is registered ----------
// An auth token signed by this key is accepted only if the key is registered for the account.
const { authToken } = check(lighter.CreateAuthToken(0, API_KEY_INDEX, ACCOUNT_INDEX));
const limits = await get(`/api/v1/accountLimits?account_index=${ACCOUNT_INDEX}`, { Authorization: authToken });
if (limits.code !== 200) throw new Error(`API key not registered for this account: ${limits.code} ${limits.message}`);
console.log("API key OK, tier:", limits.user_tier);

// ---------- 3. market decimals ----------
const books = await get("/api/v1/orderBookDetails");
const eth = books.order_book_details.find((m: any) => m.symbol === "ETH");
const toInt = (value: number, decimals: number) => Math.round(value * 10 ** decimals);

// ---------- 4. sign and send a limit order ----------
const clientOrderIndex = Date.now() % 2 ** 32;
const order = check(lighter.SignCreateOrder(
  eth.market_id,
  clientOrderIndex,
  toInt(0.01, eth.size_decimals),  // 0.01 ETH
  toInt(1000, eth.price_decimals), // $1,000: far from market, won't fill
  0,                               // isAsk: 0 = buy
  0,                               // orderType: limit
  1,                               // timeInForce: good-till-time
  0, 0,                            // reduceOnly, triggerPrice
  Date.now() + 28 * 24 * 3600e3,   // orderExpiry
  0, 0, 0,                         // integrator fields
  0, 0,                            // self-trade modes
  0, await nextNonce(),            // skipNonce, nonce
  API_KEY_INDEX, ACCOUNT_INDEX,
));
console.log("order accepted:", await sendTx(order));

// ---------- 5. cancel it ----------
const cancel = check(lighter.SignCancelOrder(
  eth.market_id, clientOrderIndex, 0, await nextNonce(), API_KEY_INDEX, ACCOUNT_INDEX,
));
console.log("cancel accepted:", await sendTx(cancel));

process.exit(0); // the Go runtime keeps Node alive otherwise

Notes on what it does:

  • Keep the WASM signer off the network. CreateClient gets an empty URL and every request goes through JS fetch. Avoid CheckClient and nonce = -1 (which asks the signer to fetch the nonce itself): both use Go's HTTP stack, which can't reach the network from WASM (you'll see dial tcp: lookup …).
  • Key check: an auth token signed by your key is only accepted if that key is registered for the account. Any non-200 code (e.g. invalid signature, couldnt find account) means the key, key index or account index is wrong.
  • Integers: prices and sizes are integers scaled by the market's price_decimals / size_decimals from orderBookDetails. See Core concepts.
  • Every function returns an object; if it has an error field, the call failed. check() turns that into an exception.

4. Run it

export LIGHTER_ACCOUNT_INDEX=123
export LIGHTER_API_KEY_INDEX=4
export LIGHTER_API_PRIVATE_KEY=0x...
# testnet: export LIGHTER_URL=https://testnet.zklighter.elliot.ai LIGHTER_CHAIN_ID=300

npx tsx quickstart.ts

Expected output:

API key OK, tier: std
order accepted: { code: 200, tx_hash: '...', predicted_execution_time_ms: ..., ... }
cancel accepted: { code: 200, tx_hash: '...', ... }
🚧

Accepted is not executed

code: 200 means the API accepted the transaction. It can still be rejected by the sequencer (e.g. insufficient margin). Confirm with the account_orders / account_tx WebSocket channels. See Account data & positions.

Reusing the signer in your app

The guides import the loader from a small module. Split it out of the script like this:

import fs from "node:fs";
import "./wasm_exec.js";

const go = new (globalThis as any).Go();
const { instance } = await WebAssembly.instantiate(fs.readFileSync("./lighter-signer.wasm"), go.importObject);
go.run(instance);

export const lighter = globalThis as any;
export function check<T extends { error?: string }>(res: T): T {
  if (res?.error) throw new Error(res.error);
  return res;
}

Running in the browser

Same code, but load the module with fetch:

await import("/wasm_exec.js");
const go = new (window as any).Go();
const { instance } = await WebAssembly.instantiateStreaming(
  fetch("/lighter-signer.wasm"), go.importObject,
);
go.run(instance);

Serve the .wasm with Content-Type: application/wasm. Pre-compressing with Brotli/Gzip is recommended. The same rules apply as in Node: pass an empty URL to CreateClient and make HTTP calls with fetch.

❗️

Never ship your own API key to a browser

In a frontend, generate a key per user on their device and register it with their wallet. See Create accounts programmatically.

Next steps


Did this page help you?