Signing Core transactions
Applications can construct and sign transactions locally, then submit them directly to Core. Depending on the operation, authorization requires an API-key signature, the account controller's signature, or both. See Accounts for the authorization model.
Lighter's TypeScript and WASM quickstart explains the JavaScript setup and signing workflow. Its transaction guides and WASM signer reference cover the existing transaction types. This page follows the same workflow for the additional transaction types available on the devnet.
Set up the signer
Follow Lighter's TypeScript and WASM quickstart, but use the evm-devnet branch of lighter-go. It retains the existing signing methods and adds WASM exports for SignSendToEvm and SignWithdrawToL1Address.
Replace the quickstart's clone command with the following.
git clone --branch evm-devnet \
https://github.com/elliottech/lighter-go.git lighter-go
cd lighter-goBuild the ./wasm/ target described in the quickstart and use the resulting lighter-signer.wasm, with wasm_exec.js from the same Go version. The new methods are not in upstream release binaries or the devnet preview version's web-wasm target.
The examples below use the quickstart's reusable signer.ts module with the devnet preview WASM binary built above. The module exports lighter and check. check throws when a signer call returns an error. The browser setup uses the same signing methods.
Generate an API-key pair
With the signer set up, call GenerateAPIKey() to generate a key pair locally.
import { lighter, check } from "./signer";
const { privateKey, publicKey } = check(lighter.GenerateAPIKey());Generating the pair does not require a wallet signature or submit a transaction. Before using the key to sign routine Core transactions, register it on the Core account.
Initialize the signing client
Set LIGHTER_URL to your network's Core API URL and LIGHTER_CHAIN_ID to its Core chain ID, not its EVM or L1 chain ID. The devnet Core chain ID is 300; see Chain Info. Do not keep the upstream quickstart's mainnet defaults.
Use the account index, API-key index, and API private key for a key already registered on that Core account.
import { lighter, check } from "./signer";
const CORE_API_URL = process.env.LIGHTER_URL!;
const CORE_CHAIN_ID = Number(process.env.LIGHTER_CHAIN_ID);
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!;
check(lighter.CreateClient(
"", API_PRIVATE_KEY, CORE_CHAIN_ID, API_KEY_INDEX, ACCOUNT_INDEX,
));
async function nextNonce(): Promise<number> {
const res = await fetch(
`${CORE_API_URL}/api/v1/nextNonce?account_index=${ACCOUNT_INDEX}&api_key_index=${API_KEY_INDEX}`,
).then(r => r.json());
if (res.code !== 200) throw new Error(res.message);
return res.nonce;
}Keep HTTP requests in JavaScript and pass an explicit nonce to the signer. Use one Core chain ID per WASM instance. The bindings take JavaScript numbers for integer arguments, so keep them within Number.MAX_SAFE_INTEGER.
Core-assigned spot orderIndex values exceed this limit because they encode the market index above the low 48 bits. When placing a spot order, set a unique, nonzero clientOrderIndex for the account, then pass it as the order index to SignCancelOrder(...) or SignModifyOrder(...). Client order indexes are at most 2^48 - 1, so they fit safely in a JavaScript number.
Sign the devnet transaction types
Choose one of the following calls. Each returns txType, txInfo, txHash, and messageToSign. txInfo is a JSON string containing the API-key signature. Signing does not submit the transaction.
Send to EVM
This example signs a transfer of 0.01 ETH from the account's spot balance. Set EVM_RECIPIENT to the recipient and LIGHTER_SEND_TO_EVM_FEE to the fee required by your network, in Core USDC base units. See Send to EVM for balance, fee, and authorization requirements.
const tx = check(lighter.SignSendToEvm(
1, // assetIndex: ETH
process.env.EVM_RECIPIENT!, // evmRecipient
1, // routeType: spot
1_000_000, // baseAmount: 0.01 ETH
Number(process.env.LIGHTER_SEND_TO_EVM_FEE), // usdcFee
await nextNonce(),
API_KEY_INDEX,
ACCOUNT_INDEX,
));Withdraw to L1 Address
This example signs a withdrawal of 0.01 ETH from the account's spot balance. Set L1_RECIPIENT to the recipient on L1. See Core to L1 for withdrawal requirements and finalization.
const tx = check(lighter.SignWithdrawToL1Address(
1, // assetIndex: ETH
process.env.L1_RECIPIENT!, // l1Recipient
1, // routeType: spot
1_000_000, // baseAmount: 0.01 ETH
await nextNonce(),
API_KEY_INDEX,
ACCOUNT_INDEX,
));Add controller authorization
The WASM signer returns messageToSign, which the account controller signs using EIP-191 personal-sign. The application then attaches the signature before submission. For these two new transaction types, the signature field is OwnerSig rather than L1Sig.
Withdraw to L1 Address always requires the EOA controller's signature. Send to EVM requires it unless the recipient's EVM-controlled Core account index is exactly the account being debited. Sending from a subaccount to its master's EVM address still requires the signature.
When the signature is required, collect and attach it as follows. Here, walletClient is a connected viem wallet client and controllerAddress is the Core master account's controlling EOA. Sign messageToSign, not txHash.
const ownerSig = await walletClient.signMessage({
account: controllerAddress,
message: tx.messageToSign,
});
tx.txInfo = JSON.stringify({
...JSON.parse(tx.txInfo),
OwnerSig: ownerSig,
});For a Send to EVM transfer that does not require controller authorization, leave txInfo unchanged. A contract controller must use its native-domain method when controller authorization is required.
Submit to Core
Submit the signed JSON to the network's Core API using sendTx.
const res = await fetch(`${CORE_API_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 (res.code !== 200) throw new Error(`sendTx ${res.code}: ${res.message}`);API acceptance does not confirm successful execution on Core. Use Lighter's transaction lookup to check the Core outcome. A transaction with status of 2 (executed) or 3 (packed) may still have failed during Core execution. Parse the JSON string in event_info and check its ae field, which is empty on success or contains a JSON-encoded error on failure. A transaction that fails this way still consumes its nonce, so fetch the next nonce before signing a retry.
For the new transaction types covered on this page, successful execution on Core is followed by delivery or finalization on the destination domain. Follow the corresponding guide after confirming success on Core.
- Send to EVM. See EVM delivery.
- Withdraw to L1 Address. See L1 withdrawal finalization.
Updated about 7 hours ago
