← Agents

Confidential transfer for agents

Every confidential-transfer build endpoint on SolKnife accepts proof bytes from the caller. The server holds no ZK keys and runs no proof generator. To drive the flow from outside a browser, an agent embeds the proof generator in its own runtime, derives the ElGamal and AES keys from a wallet signature, and computes the bytes itself. This page is the full spec.

The trust boundary, stated plainly

SolKnife's browser UI keeps the ElGamal and AES keys inside a Web Worker so they never reach the main page's JavaScript context, let alone the network. An agent that replicates the cryptography in Node has its process memory holding those keys for the duration of the operation. That is a real custody extension. Treat the agent runtime accordingly: isolated, ephemeral, not shared with other tenants.

This is the same trade-off any non-custodial wallet faces when it stops being a hardware device. We document it; we don't hide it.

The flow, in three calls

  1. Build a deterministic seed message (per token account) and sign it with the wallet's Ed25519 key. Derive the ElGamal keypair and the AES AeKeyfrom that signature, using the SPL Token CLI standard.
  2. Fetch the current confidential-transfer state via /api/confidential-transfer/state (also exposed as the ct.state MCP tool). Feed the keys plus the live ciphertexts into @solana/zk-sdk to generate the proofs.
  3. Call the matching MCP build tool with the proof bytes (base64). Sign the returned transaction with your wallet. POST the signed bytes to ct.submit.

Key derivation

The seed messages match @solana/zk-sdk's built-in ElGamalKeypair.signerMessage and AeKey.signerMessage: a confidential balance configured through SolKnife stays decryptable in the SPL Token CLI, and any tool using the same scheme stays interoperable.

The exact messages, per token account address:

// ElGamal seed
"ElGamalSecretKey" || <token_account_pubkey_bytes>

// AES (AeKey) seed
"AeKey" || <token_account_pubkey_bytes>

Determinism is safety. A wallet whose signMessage is non-deterministic produces different keys across sessions and permanently locks the user out of their own confidential balance. Sign each seed twice during onboarding; if the signatures differ, refuse to continue. The browser UI enforces this; an agent must too.

Proof spec

Five proof types appear across the build endpoints. All byte lengths are post-encoding raw bytes; the wire format is base64 on the JSON envelope. Lengths here are taken from the on-chain ZK ElGamal Proof Program (Solana mainnet) at the time of writing.

ProofBytesWhat it proves
PubkeyValidity96The ElGamal pubkey corresponds to a real ElGamal secret key. Used at Configure time.
ZeroCiphertext96An ElGamal ciphertext encrypts zero. Used by EmptyAndClose.
CiphertextCommitmentEquality320A ciphertext and a Pedersen commitment encrypt the same value. Used by Withdraw + Transfer.
BatchedGroupedCiphertext3HandlesValidity544The auditor + destination + source ciphertexts all decrypt to the same plaintext under their respective keys. Transfer only.
BatchedRangeProofU64688The withdraw amount and remaining balance both fit in u64. Withdraw only.
BatchedRangeProofU1281000The transfer amount lo/hi and remaining balance all fit in their respective ranges. Transfer only.

Ciphertexts and decryptables

Some build endpoints also expect ElGamal ciphertexts or AES decryptables alongside the proofs. Lengths:

FieldBytesDescription
decryptableZeroBalance36AES-encrypted zero balance + nonce. Used at Configure.
newDecryptableAvailable36AES-encrypted post-op available balance. Used by ApplyPending, Withdraw, Transfer.
auditorCiphertextLo / Hi64ElGamal ciphertexts of the transfer amount under the auditor pubkey. Transfer only.

Per-op input map

What every CT MCP build tool wants on top of the obvious params:

MCP toolInputs the agent must compute
ct.configure.buildpubkeyValidityProof (96) · decryptableZeroBalance (36)
ct.deposit.buildNothing extra — public balance is already public.
ct.apply.buildnewDecryptableAvailable (36) · expectedPendingCounter (u64)
ct.empty-and-close.buildzeroCiphertextProof (96)
ct.withdraw.buildequalityProof (320) · rangeProof (688, u64) · newDecryptableAvailable (36) · equalityContextAccount · rangeContextAccount (rent-funded keypairs)
ct.transfer.buildequalityProof (320) · validityProof (544, 3-handles) · rangeProof (1000, u128) · newSourceDecryptableAvailable (36) · auditorCiphertextLo (64) · auditorCiphertextHi (64) · three rent-funded context-state keypairs

The three context-state accounts on Withdraw and Transfer are short-lived: each proof verification consumes one, the on-chain instruction marks them closeable, and ct.orphans.build sweeps them if a saga aborts mid-flight.

Reference Node implementation

Install the canonical Solana SDK:

npm install @solana/zk-sdk @solana/web3.js

Derive the ElGamal + AES keys from a wallet keypair (full custody case: the agent has the secret key, not a remote-signing wallet):

import { Keypair, PublicKey } from "@solana/web3.js";
import { sign } from "@noble/curves/ed25519";
import { ElGamalKeypair, AeKey } from "@solana/zk-sdk";

const ELGAMAL_SEED = new TextEncoder().encode("ElGamalSecretKey");
const AES_SEED     = new TextEncoder().encode("AeKey");

function seed(label: Uint8Array, tokenAccount: PublicKey): Uint8Array {
  const ta = tokenAccount.toBytes();
  const out = new Uint8Array(label.length + ta.length);
  out.set(label, 0);
  out.set(ta, label.length);
  return out;
}

async function deriveKeys(
  wallet: Keypair,
  tokenAccount: PublicKey,
): Promise<{ elgamal: ElGamalKeypair; aes: AeKey }> {
  const elgamalSig = sign(seed(ELGAMAL_SEED, tokenAccount), wallet.secretKey.subarray(0, 32));
  const aesSig     = sign(seed(AES_SEED,     tokenAccount), wallet.secretKey.subarray(0, 32));
  return {
    elgamal: ElGamalKeypair.fromSignature(elgamalSig),
    aes:     AeKey.fromSignature(aesSig),
  };
}

For a Transfer, fetch state, generate the three proofs + ciphertexts, then call the build endpoint:

import {
  ProofGeneration, // module from @solana/zk-sdk that builds transfer proofs
} from "@solana/zk-sdk";

const stateRes = await fetch(
  `https://solknife.xyz/api/confidential-transfer/state?owner=${owner}&mint=${mint}`,
);
const state = (await stateRes.json()).data;

const transferAmount = 1_000_000n; // u64, in token base units
const { elgamal, aes } = await deriveKeys(wallet, new PublicKey(state.tokenAccount));

const proofs = ProofGeneration.transfer({
  sourceElgamalKeypair: elgamal,
  destinationElgamalPubkey: state.destination.elgamalPubkey,
  auditorElgamalPubkey:    state.mint.auditorElgamalPubkey, // may be null
  sourceAesKey: aes,
  currentAvailableCiphertext: state.sourceAvailableCiphertext,
  currentAvailable: state.sourceAvailable,
  transferAmount,
});

// Three transient context-state keypairs (rent funded; closed after verify)
const equalityCtx = Keypair.generate();
const validityCtx = Keypair.generate();
const rangeCtx    = Keypair.generate();

const buildRes = await fetch(
  "https://solknife.xyz/api/confidential-transfer/transfer/build",
  {
    method: "POST",
    headers: { "content-type": "application/json", cookie: sessionCookie },
    body: JSON.stringify({
      owner,
      mint,
      destinationTokenAccount: state.destination.tokenAccount,
      equalityProof:                 b64(proofs.equality),
      validityProof:                 b64(proofs.validity3Handles),
      rangeProof:                    b64(proofs.rangeU128),
      newSourceDecryptableAvailable: b64(proofs.newAesCiphertext),
      auditorCiphertextLo:           b64(proofs.auditorCiphertextLo),
      auditorCiphertextHi:           b64(proofs.auditorCiphertextHi),
      equalityContextAccount: equalityCtx.publicKey.toBase58(),
      validityContextAccount: validityCtx.publicKey.toBase58(),
      rangeContextAccount:    rangeCtx.publicKey.toBase58(),
    }),
  },
);
const { signedTransactions, lastValidBlockHeight } = (await buildRes.json()).data;
// ... sign each tx with [wallet, equalityCtx, validityCtx, rangeCtx]
// ... POST to /api/confidential-transfer/submit

The above is canonical, not copy-paste-ready: the exact field names on @solana/zk-sdk ProofGeneration.transfer may differ slightly across SDK versions. Pin the SDK version, read its TypeScript declarations, and confirm the field name to byte-length mapping before shipping.

Determinism check on first use

Before configuring an account for the first time, the agent MUST verify the wallet's signMessageis deterministic. SolKnife's browser UI does this; an agent must too. The check:

function isWellShapedSignature(sig: Uint8Array): boolean {
  return sig instanceof Uint8Array && sig.length === 64;
}

async function checkDeterminism(
  wallet: Keypair,
  tokenAccount: PublicKey,
): Promise<boolean> {
  const m = seed(ELGAMAL_SEED, tokenAccount);
  const a = sign(m, wallet.secretKey.subarray(0, 32));
  const b = sign(m, wallet.secretKey.subarray(0, 32));
  if (!isWellShapedSignature(a) || !isWellShapedSignature(b)) return false;
  return Buffer.compare(a, b) === 0;
}

A non-deterministic signer locks the user out of their own confidential balance forever. If the check fails, refuse to configure.

Auditor and observability

A Token-2022 mint configured with an auditorElGamalPubkey requires every transfer to additionally encrypt the amount to the auditor. The auditor can then decrypt all confidential transfers using its own ElGamal secret. The ct.state read tool tells you whether the mint has one and what its pubkey is. If present, your transfer proof must produce auditorCiphertextLo and Hi; if absent, the field is still sent but encrypts under a zeroed pubkey.

Further reading