Quickstart
Submit a task, poll for the result, and settle it on-chain.
This guide takes you from an API key to an on-chain state update. The router does the expensive off-chain work (fanning your task out to the operator network and aggregating their signatures) and hands back a ready-to-sign transaction. You submit that transaction from your own wallet, so the router never custodies your keys or funds.
The flow is three calls plus one on-chain submission:
- Submit a task to
POST /tasksand get atask_id. - Poll
GET /tasks/{task_id}until itsstatusisready. - Sign and submit the returned
payloadfrom your wallet.
Set these environment variables to follow along against the public testnet:
export ROUTER_URL="https://testnet.gaskiller.xyz"
export API_KEY="gk_..." # your API key (see step 2)
export RPC_URL="https://..." # an RPC endpoint for the payload's chain
export PRIVATE_KEY="0x..." # the wallet that submits the transaction
export TARGET_ADDRESS="0x..." # the contract to settle into (see step 1)1. Choose a target contract
Gas Killer settles into a contract that inherits the Gas Killer Solidity SDK,
that inheritance is what gives it the verifyAndUpdate function the payload
calls. A contract without it cannot be a target, and the router will reject the
task.
You can borrow one for this guide, or use your own.
A deployed ArraySummation on Sepolia, already wired to the live operator set:
export TARGET_ADDRESS="0xF143a9D93045474C2B573d21AC1CCe8dB2b06dbD"Its tracked function is sum(uint256[] indexes), which adds up the array
elements at the indexes you pass. The array holds 10 elements, so any index
0–9 is valid. That is the call used in the examples below.
Check it is still a live target before relying on it. true means the router
will accept tasks for it:
cast call "$TARGET_ADDRESS" "supportsInterface(bytes4)(bool)" 0x93de4531 \
--rpc-url "$RPC_URL"This is shared scratch space, not a stable fixture. Anyone else settling against it advances its transition counter, which invalidates a payload you are holding, so submit a new task if that happens. Do not build anything on it.
Inherit GasKillerSDK, wire it to the live operator set, and mark the expensive
function trackState:
import {GasKillerSDK} from "gas-killer-sdk/GasKillerSDK.sol";
contract Counter is GasKillerSDK {
uint256 public total;
constructor(address avs, address blsSigChecker) {
_setAvsAddress(avs);
_setBlsSignatureChecker(blsSigChecker);
}
function recompute(uint32 rounds) external trackState {
// the expensive path operators reproduce off-chain
}
}The Solidity Reference covers this end to end: a
complete minimal contract, the addresses to wire, and the checks to run after
deploying. Then set TARGET_ADDRESS to your deployment and carry on.
2. Get an API key
Task submission and polling are authenticated with a bearer API key (it looks
like gk_…). Keys are provisioned by the Gas Killer team. Request one with the
API key request form. Store it securely
and pass it as a bearer token on every request.
3. Submit a task
Send the task to POST /tasks with the key as a bearer token. call_data is the
ABI-encoded calldata as an array of byte values, and value is a 0x-prefixed hex
uint256. block_height is the block the off-chain execution is anchored to and
must be recent (within a few hundred blocks of head), so read it from the chain
rather than hardcoding it. A 202 response returns a task_id you'll poll in
the next step.
ACCOUNT=$(cast wallet address --private-key "$PRIVATE_KEY")
BLOCK=$(cast block-number --rpc-url "$RPC_URL")
# call_data is a JSON array of byte values, so expand the ABI-encoded calldata.
CALLDATA=$(cast calldata "sum(uint256[])" "[1,2,3]")
BYTES=$(printf '%s' "${CALLDATA#0x}" | xxd -r -p \
| od -An -tu1 -v | tr -s ' ' '\n' | grep . | paste -sd, -)
TASK_ID=$(curl -s -X POST "$ROUTER_URL/tasks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"body\": {
\"target_address\": \"$TARGET_ADDRESS\",
\"from_address\": \"$ACCOUNT\",
\"call_data\": [$BYTES],
\"value\": \"0x0\",
\"block_height\": $BLOCK
}
}" | jq -r .task_id)
echo "$TASK_ID"import { createPublicClient, encodeFunctionData, http, parseAbi } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { sepolia } from "viem/chains"
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const publicClient = createPublicClient({
chain: sepolia,
transport: http(process.env.RPC_URL),
})
const blockHeight = await publicClient.getBlockNumber()
const callData = encodeFunctionData({
abi: parseAbi(["function sum(uint256[] indexes)"]),
functionName: "sum",
args: [[1n, 2n, 3n]],
})
const res = await fetch(`${process.env.ROUTER_URL}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
body: {
target_address: process.env.TARGET_ADDRESS,
from_address: account.address,
// call_data is a byte array, not a hex string.
call_data: Array.from(Buffer.from(callData.slice(2), "hex")),
value: "0x0",
block_height: Number(blockHeight),
},
}),
})
const { task_id } = await res.json()
console.log(task_id)The 202 body is { "task_id": "…", "status": "queued" }. See
Submit a compute task for every field and error.
transition_index
Omit transition_index (or send null / "auto") to let the router assign the
next available state-transition slot at dequeue time, which is what makes safe
parallel submissions possible. Send an integer only when you need to target a
specific slot.
4. Poll until ready
Poll GET /tasks/{task_id} until status becomes ready. The task moves
queued → processing → ready; a ready task carries a payload object. If
it ends in failed or expired instead, the error field explains why.
# Poll every 2s until the task leaves the queue.
while :; do
TASK=$(curl -s "$ROUTER_URL/tasks/$TASK_ID" -H "Authorization: Bearer $API_KEY")
STATUS=$(echo "$TASK" | jq -r .status)
echo "status: $STATUS"
[ "$STATUS" = "ready" ] && break
case "$STATUS" in failed|expired) echo "$TASK" | jq .error; exit 1;; esac
sleep 2
done
echo "$TASK" | jq .payloadasync function pollUntilReady(taskId: string) {
while (true) {
const res = await fetch(`${process.env.ROUTER_URL}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(`${error.code}: ${error.message}`)
}
const task = await res.json()
if (task.status === "ready") return task.payload
if (task.status === "failed" || task.status === "expired") {
throw new Error(`task ${task.status}: ${task.error}`)
}
await new Promise((r) => setTimeout(r, 2000))
}
}
const payload = await pollUntilReady(task_id)A ready response looks like this:
{
"task_id": "3f8c1e02-9a4b-4c7d-8e1f-2b6a5c9d0e11",
"status": "ready",
"created_at": 1753180800,
"updated_at": 1753180812,
"error": null,
"payload": {
"to": "0xF143a9D93045474C2B573d21AC1CCe8dB2b06dbD",
"data": "0x93de4531000000000000000000000000000000000000000000000000000000000000002a",
"value": "0x0",
"chain_id": 11155111,
"estimated_gas": 257468,
"valid_until_block": 11535702
}
}The payload is only valid until valid_until_block. Submit before that block;
afterwards, or if the target's on-chain state has already advanced,
GET /tasks/{task_id} returns 409 PAYLOAD_EXPIRED and you must submit a new
task. Always fetch the single task (not the list endpoint) immediately before
submitting, so you get the freshness-checked payload.
5. Sign and submit the payload
The payload is a complete transaction request. Sign it with your wallet and
broadcast it as-is: to, data, and value are all supplied by the
router. Use an RPC endpoint for the chain named in payload.chain_id (the
testnet settles on Sepolia, 11155111).
Extract the fields with jq, then broadcast with Foundry's
cast send:
TO=$(echo "$TASK" | jq -r .payload.to)
DATA=$(echo "$TASK" | jq -r .payload.data)
VALUE=$(echo "$TASK" | jq -r .payload.value)
cast send "$TO" "$DATA" \
--value "$VALUE" \
--rpc-url "$RPC_URL" \
--private-key "$PRIVATE_KEY"Submit with viem. Match the chain to payload.chain_id:
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { sepolia } from "viem/chains"
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(process.env.RPC_URL),
})
const hash = await walletClient.sendTransaction({
to: payload.to as `0x${string}`,
data: payload.data as `0x${string}`,
value: BigInt(payload.value),
gas: BigInt(payload.estimated_gas),
})
console.log("submitted:", hash)estimated_gas is a hint from the router's eth_estimateGas; most wallets and
libraries will re-estimate, so passing it is optional.
6. Handle errors
Every error uses the same envelope, with a stable code:
{ "error": { "code": "QUEUE_FULL", "message": "Service at capacity, please try again in a few minutes" } }RATE_LIMITED(429) means you've exceeded your key's request rate (60 requests/minute by default). Wait for theRetry-Afterheader value, in seconds, then retry.QUEUE_FULL,RPC_UNAVAILABLE(503) are transient, so retry after a short delay.QUEUE_FULLalso sends aRetry-Afterheader.PAYLOAD_EXPIRED(409) means the payload is stale, so submit a fresh task and poll again.4xxvalidation errors (INVALID_ADDRESS,STALE_BLOCK,TRANSITION_MISMATCH, …) mean the request needs fixing before it can succeed.
See Submit a compute task and Get task status for the full status-code and error matrix for each endpoint.
