[ BLOG ]

Your Solana Agent Never Holds the Key: HSM Signing on the Intents API

1Claw signs and broadcasts Solana transactions from inside the HSM. SOL transfers, SPL token interactions, automatic ATA derivation and blockhash fetching. Per-agent guardrails on chains, destinations, and spend caps. The agent never sees the private key.

If you run a Solana agent, the setup is probably familiar: generate a keypair, load it from a JSON file or decode one out of an environment variable, hand it to the Solana SDK, and sign transactions directly in the process.

const keypair = Keypair.fromSecretKey(
  new Uint8Array(JSON.parse(process.env.WALLET_SECRET_KEY!))
);

That key is in memory the moment that line runs. It stays there for the lifetime of the process. Prompt injection can surface environment variables. A logging library that serializes process state can capture it. A shared container environment means neighbor processes can read it from /proc. And unlike revoking an API key, a copied Ed25519 private key is permanent.

We shipped Solana on the Intents API so agents stop holding keys at all. The agent sends chain, recipient, and amount. The server builds the transaction, signs it in the HSM, and broadcasts. Back comes a tx_hash, not a key.

Getting a signing key

A human provisions the key once. Agents cannot create or rotate keys themselves; those endpoints return 403 on purpose.

curl -X POST "https://api.1claw.xyz/v1/agents/$AGENT_ID/signing-keys" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "chain": "solana" }'

You get back an Ed25519 public key and a Base58-encoded address. The private key goes into the org's __agent-keys vault, encrypted under Cloud KMS. The API never returns the private key.

{
  "chain": "solana",
  "curve": "ed25519",
  "address": "CrfSSxXZ46EjHLC8MQkcX4bA...",
  "public_key": "b0297a8a055cd01e...",
  "key_version": 1
}

Sending SOL and SPL tokens

Signing runs through the solana-sdk crate. The server fetches the latest blockhash automatically before constructing the transaction. You do not manage blockhash freshness.

# Native SOL transfer on Devnet
curl -X POST "https://api.1claw.xyz/v1/agents/$AGENT_ID/transactions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "chain": "solana-devnet",
    "to": "9xQ...",
    "value": "0.25"
  }'

value is plain SOL as a decimal string. "0.25" is 250,000,000 lamports. The server constructs the transfer instruction, signs, and broadcasts.

For SPL token transfers, add token_mint and token_decimals:

curl -X POST "https://api.1claw.xyz/v1/agents/$AGENT_ID/transactions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "chain": "solana-devnet",
    "to": "9xQ...",
    "value": "5",
    "token_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "token_decimals": 6
  }'

Associated Token Account (ATA) derivation is handled automatically using native PDA derivation via Pubkey::find_program_address. If the recipient does not have an ATA for that mint, it gets created in the same transaction. The SDK auto-generates an Idempotency-Key header on every call so duplicate submissions within 24 hours are deduplicated and retries are safe.

Chains: solana (mainnet-beta), solana-devnet, solana-testnet. The address format is the same across all three networks.

Where signing runs

api.1claw.xyz uses Cloud KMS HSM in Cloud Run. Key material is decrypted inside the HSM boundary, used once, then cleared from memory.

shroud.1claw.xyz and intents.1claw.xyz run the same flow inside AMD SEV-SNP confidential VMs on GKE. Keys only exist in confidential memory inside the enclave. That is the option if you need attestation or a stricter compliance story.

Both support sign-only via POST /v1/agents/:id/transactions/sign. You get the signed transaction back and broadcast it yourself. Useful for Jito bundle submissions or any MEV protection flow where you need custody of the signed transaction before it hits the mempool.

The agent never gets the key

Flip intents_api_enabled: true on an agent and two things happen at once: it can call the transaction endpoints, and it gets a 403 if it tries to read private_key or ssh_key secrets through the normal vault API. That is enforced in the handler, not documented as a guideline.

Guardrails run before signing

Blocking key reads closes the obvious exfil path. Guardrails cap what a compromised agent can actually spend. Every Solana submit runs through guardrail checks before the HSM unwraps anything. Configure them once on the agent via dashboard, SDK, or CLI.

  • Allowed chains (tx_allowed_chains): lock an agent to solana-devnet during dev, or solana only in prod
  • Destination allowlist (tx_to_allowlist): only pay addresses you have pre-approved. Anything else returns 403 at signing time
  • Per-transaction cap (tx_max_value): max amount in one send, in SOL for Solana agents ("1.0" = 1 SOL)
  • Daily spend cap (tx_daily_limit): rolling 24-hour limit on Solana-family spend only. The server tracks tx_spent_today_by_chain.solana, not a cross-chain total
  • Per-chain overrides (per_chain_guardrails): set Solana-specific max_value, daily_limit, and to_allowlist under a solana key. Strictest of global and per-chain wins

Example: a token distribution agent on Devnet locked to one recipient:

await client.agents.update(agentId, {
  intents_api_enabled: true,
  tx_allowed_chains: ["solana-devnet"],
  per_chain_guardrails: {
    solana: {
      to_allowlist: ["9xQYourRecipient..."],
      max_value: "10",
      daily_limit: "100",
    },
  },
});

Prompt injection cannot talk its way around this. The agent submits an intent, the server checks the allowlist and caps, only then does signing happen. If anything fails, you get 403 and nothing hits the chain.

Rotate and revoke

OperationEndpoint
ProvisionPOST /v1/agents/{id}/signing-keys
ListGET /v1/agents/{id}/signing-keys
RotatePOST /v1/agents/{id}/signing-keys/solana/rotate
DeactivateDELETE /v1/agents/{id}/signing-keys/solana

Rotation mints a new Ed25519 keypair in the HSM, bumps key_version, and kills the old key immediately. Your agent code does not need to change.

Try Devnet first

Get free devnet SOL from faucet.solana.com (GitHub login required) or via CLI:

solana airdrop 2 <address> --url devnet

View transactions on explorer.solana.com (select Devnet in the top-right). The faucet drip is fast but wait for the transaction to confirm before submitting a spend.

Docs: multi-chain signing keys · non-EVM transaction signing