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
- Node.js 22+ and Go 1.23+
- A Lighter account (deposit once via app.lighter.xyz or see Deposits, Transfers and Withdrawals)
- An API key registered to that account: its private key, API key index (4–254), and your account index. Create one at app.lighter.xyz or programmatically – see Create accounts programmatically.
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.jsmust 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.json3. 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 otherwiseNotes on what it does:
- Keep the WASM signer off the network.
CreateClientgets an empty URL and every request goes through JSfetch. AvoidCheckClientandnonce = -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 seedial 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_decimalsfromorderBookDetails. See Core concepts. - Every function returns an object; if it has an
errorfield, 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.tsExpected 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: 200means the API accepted the transaction. It can still be rejected by the sequencer (e.g. insufficient margin). Confirm with theaccount_orders/account_txWebSocket 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 browserIn a frontend, generate a key per user on their device and register it with their wallet. See Create accounts programmatically.
Next steps
- Signing Transactions – market orders, TP/SL, modify, batch
- Market data – stream order books and trades
- WASM signer functions – all 20+ functions and their arguments
- Looking for a complete TypeScript client state store and WebSocket pipeline? See elliottech/lighter-ts.
Updated 3 days ago
