Gas Killer Docs

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:

  1. Submit a task to POST /tasks and get a task_id.
  2. Poll GET /tasks/{task_id} until its status is ready.
  3. Sign and submit the returned payload from 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 09 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 queuedprocessingready; 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 .payload
async 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 the Retry-After header value, in seconds, then retry.
  • QUEUE_FULL, RPC_UNAVAILABLE (503) are transient, so retry after a short delay. QUEUE_FULL also sends a Retry-After header.
  • PAYLOAD_EXPIRED (409) means the payload is stale, so submit a fresh task and poll again.
  • 4xx validation 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.

On this page