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
- 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. - Fetch the current confidential-transfer state via
/api/confidential-transfer/state(also exposed as thect.stateMCP tool). Feed the keys plus the live ciphertexts into@solana/zk-sdkto generate the proofs. - 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.
| Proof | Bytes | What it proves |
|---|---|---|
| PubkeyValidity | 96 | The ElGamal pubkey corresponds to a real ElGamal secret key. Used at Configure time. |
| ZeroCiphertext | 96 | An ElGamal ciphertext encrypts zero. Used by EmptyAndClose. |
| CiphertextCommitmentEquality | 320 | A ciphertext and a Pedersen commitment encrypt the same value. Used by Withdraw + Transfer. |
| BatchedGroupedCiphertext3HandlesValidity | 544 | The auditor + destination + source ciphertexts all decrypt to the same plaintext under their respective keys. Transfer only. |
| BatchedRangeProofU64 | 688 | The withdraw amount and remaining balance both fit in u64. Withdraw only. |
| BatchedRangeProofU128 | 1000 | The 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:
| Field | Bytes | Description |
|---|---|---|
| decryptableZeroBalance | 36 | AES-encrypted zero balance + nonce. Used at Configure. |
| newDecryptableAvailable | 36 | AES-encrypted post-op available balance. Used by ApplyPending, Withdraw, Transfer. |
| auditorCiphertextLo / Hi | 64 | ElGamal 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 tool | Inputs the agent must compute |
|---|---|
| ct.configure.build | pubkeyValidityProof (96) · decryptableZeroBalance (36) |
| ct.deposit.build | Nothing extra — public balance is already public. |
| ct.apply.build | newDecryptableAvailable (36) · expectedPendingCounter (u64) |
| ct.empty-and-close.build | zeroCiphertextProof (96) |
| ct.withdraw.build | equalityProof (320) · rangeProof (688, u64) · newDecryptableAvailable (36) · equalityContextAccount · rangeContextAccount (rent-funded keypairs) |
| ct.transfer.build | equalityProof (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.jsDerive 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/submitThe 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
- How confidential transfer works on Solana — the protocol explainer the browser UI links to
- solana-program-library / zk-token-sdk — the upstream Rust + WASM proof generator
- @solana/zk-sdk on npm — Node + browser-compatible wrapper
- /api/manifest — the machine-readable tool inventory (every CT build tool listed)