Skip to content

SDK: trade with a key signer PEM

A TypeScript bot built on @candledottv/agent-sdk can trade its API key’s Solana TEE wallets with the key’s key signer, exported as a PEM file by the CLI. The SDK already signs with a P-256 PEM; this page shows where that PEM comes from and how to hand it to the client.

Read Run a bot on a headless machine first. The same rules apply to an SDK bot: the box holds the API key and the signer, never a device token.

candle tee signer new --key <prefix> --out <path> writes the signer’s private key to <path> as a PKCS#8 PEM, mode 0600, and still keeps it in the CLI’s secret store. It exists for a process that cannot read that store. Before you use it:

  • It is plaintext on disk. Anyone who can read the file, or a backup or snapshot of that disk, holds the signer of every wallet on the key. With the API key as well, they can trade those wallets within the key’s scopes and caps.
  • It is opt-in. Without --out nothing is written outside the secret store. Prefer running the bot through the CLI when you can.
  • Nothing keeps it in step. When you rotate the signer or keys signer move retires an old one, the CLI updates its store and leaves your PEM alone. Delete an old PEM yourself.
  • Do not log the bot box in. A box with a device token beside the signer can approve its own signer request. See Do not log the bot box in.

Keep the file on the box, readable only by the bot’s user, and out of source control, images and shared volumes.

On the bot box, as the bot’s user, with the key’s API key in CANDLE_API_KEY:

Terminal window
candle tee signer new --key tr-2 --out /var/lib/candle-bot/key-signer.pem

It prints the signer’s fingerprint (CNDL-XXXX-XXXX-XXXX), an approval link and a code, and waits. Approve it from your own machine, typing the full fingerprint, then promote onto the key from the vault machine (candle vault promote-batch --to-key tr-2 ...). Wallets promoted onto the key are owned by this signer at once. Wallets the key held before keep their old signer until candle keys signer move runs on the machine that holds it.

CandleClient signs a linked wallet’s trade with the PEM its secretStore returns for that wallet’s id. A key signer owns many wallets, so the store returns the same PEM for each wallet that signer owns, and nothing for the rest:

import { createHash, createPublicKey } from "node:crypto"
import { readFileSync } from "node:fs"
import { CandleClient, type SecretStore } from "@candledottv/agent-sdk"
const apiUrl = "https://api.alpha.candle.tv"
const apiKey = process.env.CANDLE_API_KEY as string
const pem = readFileSync("/var/lib/candle-bot/key-signer.pem", "utf8")
async function get(path: string): Promise<any> {
const res = await fetch(`${apiUrl}${path}`, { headers: { "x-api-key": apiKey } })
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`)
return res.json()
}
// The key's active signer must be this PEM. Compare the full SPKI hash, never the display form.
const signer = await get("/api/v1/agent/keys/self/signer")
const spki = createPublicKey(pem).export({ type: "spki", format: "der" })
const spkiSha256 = createHash("sha256").update(spki).digest("hex")
if (signer.state !== "active" || signer.spkiSha256 !== spkiSha256) {
throw new Error(`This PEM is not key ${signer.keyPrefix}'s active signer (${signer.fingerprint})`)
}
// The TEE wallets bound to this key, and which of them this signer owns.
const wallets: Array<{ id: string; privyWalletId: string; chain: string; signerQuorumId?: string; active: boolean }> = []
let privyAppId = ""
let cursor: string | null = null
do {
const page = await get(`/api/v1/agent/wallets/trading${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`)
privyAppId = page.privyAppId
wallets.push(...page.page)
cursor = page.isDone ? null : page.continueCursor
} while (cursor)
const owned = new Set(wallets.filter((w) => w.signerQuorumId === signer.signerQuorumId).map((w) => w.id))
const keySignerStore: SecretStore = {
get: async (walletId) => (owned.has(walletId) ? pem : null),
set: async () => {
throw new Error("The key signer PEM is read-only")
},
delete: async () => {},
}
const candle = new CandleClient({ apiUrl, apiKey, privyAppId, secretStore: keySignerStore })
const wallet = wallets.find((w) => owned.has(w.id) && w.chain === "solana" && w.active)
if (!wallet) throw new Error("No active Solana TEE wallet on this signer yet")
const result = await candle.trade({
mint: "<mint>",
side: "buy",
amountRaw: "10000000", // 0.01 SOL
from: { linkedWalletId: wallet.id, privyWalletId: wallet.privyWalletId },
})
console.log(result)
  • GET /api/v1/agent/keys/self/signer answers with the calling key’s signer: state (active, pending or none), fingerprint, spkiSha256, and signerQuorumId once it is active, plus the key’s wallets as onSigner, legacy and moving.
  • GET /api/v1/agent/wallets/trading lists the TEE wallets bound to the calling key, with privyWalletId, signerQuorumId and active, and the deployment’s privyAppId, which the client needs to sign.
  • A wallet whose signerQuorumId is not the active one is owned by an older signer or a per-wallet signer on another machine. This PEM cannot sign for it: the store returns null, and the SDK throws before it signs anything. Filter on owned before you build, as above. Run candle keys signer move <prefix> on the machine that holds its signer.
  • Refresh the set when you promote or move wallets, or re-read it on each start.
  • Hood TEE wallets. A Hood TEE trade is sequenced: Candle hands out one leg at a time, with the nonce and gas it set, and the CLI signs exactly that leg. The SDK does not drive that sequence. Trade Hood TEE wallets with candle swap.
  • Owner changes. Moving wallets onto a signer (keys signer move, tee rebind onto a key with a signer) is a CLI job. The SDK has no call for it.
  • Store management. The SDK’s KeychainSecretStore reads per-wallet signers (wallet_signer_<id>) written by candle wallets import. It does not read key signers; that is what the PEM is for.