All posts

Solana on KeeperHub: A Devnet Tutorial

Solana on KeeperHub part 3 of 3: the devnet tutorial

This tutorial walks through every Solana node KeeperHub ships. See the announcement for an overview, or why Solana, why now, and how we built it in two weeks for the background.

Every node runs end to end below, on Solana devnet, chain ID 103. No real funds are involved, and none of these examples should be pointed at mainnet as written.

Each workflow was created by handing a plain-English sentence to an agent with access to the kh CLI and the KeeperHub MCP server. The prompts are reproduced verbatim, followed by the node configuration they produced, so you can build the same workflow by hand if you prefer.


Setup

Step 1: Confirm your organization has a Solana address

Every KeeperHub organization has one wallet, held in Turnkey. Solana support adds a Solana address to it.

kh wallet balance

Note the Solana address; it is referred to below as <ORG_WALLET_ADDRESS>.

If no Solana address appears: new organizations get one at signup, but organizations created before Solana was enabled were provisioned EVM-only and need a backfill. That is a KeeperHub-side operation: the problem is not your configuration.

Step 2: Stand up a funder wallet

The instinct is to point a faucet at the wallet you intend to automate. Don't. Create a separate funder wallet, fund that once, and top up your organization wallet from it. The funder is reusable across many runs, and it keeps faucet rate-limiting off your critical path.

solana-keygen new -o funder.json
solana-keygen pubkey funder.json

No local Solana toolchain? The Agave image has everything, though you have to override its entrypoint, which otherwise tries to generate its own keypair:

docker run --rm -u $(id -u):$(id -g) -e HOME=/w -v "$PWD":/w \
  --entrypoint solana-keygen anzaxyz/agave:v2.1.11 new -o /w/funder.json

Fill it from the Solana faucet (1 to 5 SOL per request), or from the CLI:

solana airdrop 2 <FUNDER_PUBKEY> --url devnet

Airdrops fail intermittently when the faucet is drained. Retry, or use the web form.

Step 3: Fund the organization wallet

solana transfer --from funder.json --fee-payer funder.json \
  --url https://api.devnet.solana.com --allow-unfunded-recipient \
  <ORG_WALLET_ADDRESS> 1

--allow-unfunded-recipient is required the first time: until it receives its first lamports, the account does not exist on-chain.

One SOL is generous. Fees run about 5,000 lamports per signature, and the examples that spend cap themselves at 0.05 SOL and below.

Step 4: Mint a token worth moving

The SPL transfer example needs more than a token that exists. It needs an associated token account (ATA) owned by your organization wallet, holding a balance. A wallet full of SOL but with no ATA for the mint cannot send that token, and the failure message is not obvious.

spl-token create-token --url devnet --fee-payer funder.json --decimals 6
spl-token create-account <MINT> --owner <ORG_WALLET_ADDRESS> \
  --fee-payer funder.json --url devnet
spl-token mint <MINT> 100 <ATA_FROM_PREVIOUS_STEP> \
  --mint-authority funder.json --fee-payer funder.json --url devnet

create-account prints the ATA address; pass it to mint. Confirm with:

spl-token accounts --owner <ORG_WALLET_ADDRESS> --url devnet

Step 5: Know the node shape

If you are creating workflows from the CLI rather than the UI, --nodes-file takes an object with nodes and edges arrays. Each node looks like this:

{
  "id": "trigger-1",
  "type": "trigger",
  "position": { "x": 100, "y": 200 },
  "data": {
    "label": "Trigger",
    "type": "trigger",
    "config": { "triggerType": "Block", "network": "103", "blockInterval": "20" },
    "status": "idle"
  }
}

Action nodes are the same shape with "type": "action" and a config.actionType of web3/<slug>. Workflows are created disabled; see Things that will catch you.

With those five steps done, every node below is ready to run.


Trigger 1: Waking Up on Blocks

Prompt: On Solana Devnet, set up a workflow that wakes up every 20 blocks and checks the SOL balance of <ORG_WALLET_ADDRESS>. Use the organization's Solana wallet integration.

The simplest possible heartbeat, and the one worth watching in the logs the first time: when it goes live, the reconciler opens a slot subscription and starts enqueuing blocks against your workflow ID.

{
  "nodes": [
    {
      "id": "trigger-1",
      "type": "trigger",
      "position": { "x": 100, "y": 200 },
      "data": {
        "label": "Trigger",
        "type": "trigger",
        "config": { "triggerType": "Block", "network": "103", "blockInterval": "20" },
        "status": "idle"
      }
    },
    {
      "id": "action-1",
      "type": "action",
      "position": { "x": 100, "y": 340 },
      "data": {
        "label": "Check Balance",
        "type": "action",
        "config": {
          "actionType": "web3/check-balance",
          "network": "103",
          "address": "<ORG_WALLET_ADDRESS>"
        },
        "status": "idle"
      }
    }
  ],
  "edges": [{ "id": "e1", "source": "trigger-1", "target": "action-1" }]
}
kh workflow create --name "Solana block heartbeat" --nodes-file block-heartbeat.json
kh workflow enable <WORKFLOW_ID>

Field differences from EVM. On Solana the Block trigger emits chainType: "solana", slot, blockHeight, blockhash (lower-case h, distinct from the EVM blockHash), blockTime and parentSlot. The EVM fields are absent. If a downstream node reads blockNumber, change it to read slot.

web3/check-balance returns balance in SOL and balanceWei in lamports. The field name is an EVM holdover; the value is correct.

Warning: leave this one disabled unless you are actively testing it. Twenty blocks on devnet is about eight seconds. Ours accumulated thirty executions in three minutes, the single easiest way to burn through your execution quota by accident. A real workflow wants a much larger interval.

Trigger 2: Waking Up on a Program Event

That's the simplest trigger. The second is where Solana's shape actually shows up.

Prompt: On Solana Devnet, watch the program <PROGRAM_ID> for its Pinged event, and whenever that event fires, check the SOL balance of <ORG_WALLET_ADDRESS>. Here is the program's Anchor IDL.

This is where the IDL earns its place. A Solana event is a base64 blob in a program log, prefixed by an eight-byte discriminator, the first eight bytes of sha256("event:<Name>"). Without the IDL there is no way to know that a particular prefix means Pinged rather than anything else, and no way to decode the fields that follow it.

The trigger reuses the EVM Blockchain Event vocabulary, with Solana meanings:

Field Solana meaning
network 103
contractAddress The program ID (base58)
contractABI The Anchor IDL, as JSON
eventName The event name as declared in the IDL

We deployed a small Anchor program on devnet to drive this example. Its ping(note: u64) instruction emits a Pinged { value: u64, ts: i64 } event:

  • Program: 4dtqiUjV99qLMxXfy4FPZ5zawHfqphwPgvyL3fWEi19F (kh_emitter v0.1.0)
  • ping discriminator: [173, 0, 94, 236, 73, 133, 225, 153]
  • Pinged discriminator: [50, 38, 24, 188, 75, 109, 130, 119]
{
  "id": "trigger-1",
  "type": "trigger",
  "position": { "x": 100, "y": 200 },
  "data": {
    "label": "Trigger",
    "type": "trigger",
    "config": {
      "triggerType": "Event",
      "network": "103",
      "contractAddress": "4dtqiUjV99qLMxXfy4FPZ5zawHfqphwPgvyL3fWEi19F",
      "contractABI": "{\"address\":\"4dtqiUjV99qLMxXfy4FPZ5zawHfqphwPgvyL3fWEi19F\", ... }",
      "eventName": "Pinged"
    },
    "status": "idle"
  }
}

What the first action receives: chainType, programId, signature, slot, commitment, the raw logs array, and, when an IDL and event name are supplied, eventName plus a decoded events array. Every numeric leaf is stringified, so a u64 arrives as a decimal string and a pubkey as base58.

Raw mode. Omit the IDL and the trigger still works: it fires on any transaction that touches the program and hands you the logs undecoded. That is the escape hatch for programs with no published IDL.

Action 1: Moving Native SOL

That's both triggers. The four actions below are what a triggered workflow can actually do, and they get progressively more general: each one the fallback for when the last isn't enough.

Prompt: On Solana Devnet, create a manually-triggered workflow that transfers 0.01 SOL from the organization's Solana wallet back to itself.

A self-transfer, deliberately. It moves nothing out of your organization, costs one signature fee, and proves the signing path works end to end before you point it at a real recipient.

{
  "actionType": "web3/transfer-funds",
  "network": "103",
  "amount": "0.01",
  "recipientAddress": "<ORG_WALLET_ADDRESS>"
}

This is the same node EVM workflows use for ETH. There is no Solana-specific configuration: the adapter routes on chain ID.

Action 2: Moving an SPL Token

Prompt: On Solana Devnet, create a manually-triggered workflow that transfers 0.1 of the SPL token with mint <MINT> from the organization's Solana wallet back to itself.

Also a self-transfer, for the same reason. This is the example that needs the ATA from setup step 4.

{
  "actionType": "web3/transfer-spl-token",
  "network": "103",
  "mint": "<MINT>",
  "amount": "0.1",
  "recipientAddress": "<ORG_WALLET_ADDRESS>"
}

Two details worth knowing:

  • Decimals are read from the mint account at execution time. You pass a human-readable amount, never a raw unit count.
  • A missing recipient ATA is created for you, and the sender pays its rent. The output field createdRecipientAccount tells you whether that happened: it is the difference between a transfer costing one signature fee and one costing about 0.002 SOL.

Action 3: Calling an Anchor Program

Prompt: On Solana Devnet, create a manually-triggered workflow that calls the ping instruction on the Anchor program <PROGRAM_ID> with the argument note = 42 and no accounts. Cap the spend at 0.02 SOL.

The IDL handles discriminator and argument encoding, so both the prompt and the config talk about ping and note = 42 rather than bytes.

{
  "actionType": "web3/call-solana-program-anchor",
  "network": "103",
  "programId": "4dtqiUjV99qLMxXfy4FPZ5zawHfqphwPgvyL3fWEi19F",
  "idl": "{ ...the Anchor IDL... }",
  "instruction": "ping",
  "args": { "note": "42" },
  "accounts": {},
  "maxSol": "0.02"
}

Validation happens before anything is built. Calling ping with no note argument is rejected with Missing argument: note.

Encoding rules. Integers wider than 32 bits are passed as strings, pubkeys as base58, bytes as 0x-hex, base64 or a byte array.

Accounts. Those with a fixed address in the IDL are filled automatically. A signer slot left empty defaults to the organization wallet, which is the only account permitted to sign.

Requires Anchor 0.30 or newer, since the node relies on per-instruction discriminators being present in the IDL.

Action 4: Sending a Raw Instruction

Prompt: On Solana Devnet, create a manually-triggered workflow that sends a raw instruction to program <PROGRAM_ID> with no accounts and base64 instruction data rQBe7EmF4ZkHAAAAAAAAAA==. Cap the spend at 0.05 SOL.

The escape hatch. You supply the bytes and the account list yourself, and anything a Solana transaction can express is expressible here.

{
  "actionType": "web3/send-raw-solana-instruction",
  "network": "103",
  "instructions": [
    {
      "programId": "4dtqiUjV99qLMxXfy4FPZ5zawHfqphwPgvyL3fWEi19F",
      "accounts": [],
      "data": "rQBe7EmF4ZkHAAAAAAAAAA=="
    }
  ],
  "maxSol": "0.05"
}
  • instructions is an array, so multiple instructions land atomically in one transaction.
  • Each account entry is { pubkey, isSigner, isWritable }.
  • data is standard base64 or 0x-hex.
  • Only the organization wallet may be marked isSigner, and it is always the fee payer.

What that payload actually is. Decoded, it is sixteen bytes: the ping discriminator [173, 0, 94, 236, 73, 133, 225, 153] followed by 7 as a little-endian u64. It is the same instruction Action 3 calls through the IDL, hand-encoded with a different argument, which makes the two examples directly comparable, and makes clear exactly how much work the IDL is doing for you in Action 3.

The Spending Cap Is Not Optional

Actions 3 and 4 both needed a maxSol field with no explanation yet. Here's why it's not optional: both write escape hatches require a maxSol ceiling, and this is not a lint rule you can argue your way past. Creating the node without it is rejected with 422 INVALID_ACTION_CONFIG on the CLI, on MCP, and again at execution.

Why Solana specifically. On EVM you can read the value a transaction moves out of the transaction itself. On Solana you cannot: an Anchor instruction can move lamports through a CPI that appears nowhere in its encoded arguments, and a raw instruction is opaque by construction.

So the cap is enforced twice:

  1. Charged against the organization's daily value cap before the transaction is built.
  2. Checked against the simulated balance change before it is submitted. If simulation shows a larger outflow than you declared, the transaction is rejected rather than sent.

Warning: set the cap above the base signature fee. A cap of 0.000004 SOL is 4,000 lamports, less than the 5,000 a single signature costs, and the workflow will refuse its own transaction.

Treat both nodes as trusted spending access to the organization wallet, because that is what they are.

Chaining: When One Workflow Triggers Another

With spending covered, here's what happens when two of these workflows start talking to each other: the result we didn't plan for, and the one most worth keeping.

Run the Anchor workflow. It calls ping(note = 42), the program emits Pinged, and the event-trigger workflow detects that event and runs on its own. We measured about two seconds between them, which is WebSocket detection latency.

That the second workflow ran off the first one's transaction, rather than by coincidence, is readable in the transaction log:

Program log: Instruction: Ping
Program data: MiYYvEttgncqAAAAAAAAAKYFaGoAAAAA

Base64-decoded, the first eight bytes are 32 26 18 BC 4B 6D 82 77, byte for byte the Pinged discriminator declared in the IDL. The next eight are the value field, 0x2A, which is 42. The event the second workflow consumed is unambiguously the one the first produced.

One workflow signing a transaction that wakes another is the shape most real automation takes. It is worth building deliberately rather than discovering by accident.

Things That Will Catch You

That trick only works because everything above behaved exactly as documented. Here's where it won't.

Workflows are created disabled

Every workflow (CLI, MCP or UI) starts disabled and does nothing until you turn it on.

kh workflow enable <WORKFLOW_ID>     # aliases: resume, activate
kh workflow disable <WORKFLOW_ID>    # alias: pause

Manual triggers are the exception. kh workflow run executes a manually-triggered workflow even while it is disabled, because the enabled flag governs automatic triggers only. All four action examples above ran successfully while switched off.

On an older CLI? kh workflow enable landed in v0.13.0. Before that the capability existed only as resume, named for the inverse of pause, which meant readers scanning the verb list concluded there was no way to enable a workflow at all and reached for the API. resume does the same thing, and create_workflow over MCP takes an enabled argument directly.

Creation is not validation

Node config is only lightly checked on the way in. network and actionType are not validated at all, and an integrationId matching no integration is accepted. A successful create is not evidence that the workflow runs. Misconfiguration surfaces at execution time. The one exception is maxSol, which is checked at create.

A mint address is not an EVM address

If you are building config by hand, note that the SPL token field is keyed mint, not mintAddress. Anything ending in address gets EVM checksum treatment from the field renderer, which mangles base58.


If you are automating on Solana and something here does not match what you are seeing, get in touch. We are happy to dig into the details.

Related articles

Stay in the loop

Get the latest on Web3 automation, product updates, and technical deep dives delivered to your inbox.