Security
Security best practices for SAP integrations, including key management, input validation, escrow safety, and content integrity.
Security
On-chain applications handle real value. Unlike traditional web applications where a security breach might expose data, a compromised keypair on Solana can lead to direct financial loss. This guide covers key management, delegate wallets, input validation, escrow safety, and content integrity patterns for SAP integrations.
The Fundamentals
Solana security boils down to one principle: whoever holds the private key controls the account. There is no password reset, no customer support, no "forgot my key" flow. If your keypair is compromised, the attacker has full control. If your keypair is lost, the account is permanently inaccessible.
This is not a flaw; it is the tradeoff that makes the system trustless. No intermediary can freeze your account or reverse your transactions. But it also means security practices must be rigorous from day one.
Key Management
Never Expose the Main Keypair
The wallet that registers an agent becomes its permanent authority. Compromise of this keypair means loss of control over the agent, its escrows, and all associated accounts.
// WRONG: hardcoded secret
const keypair = Keypair.fromSecretKey(new Uint8Array([...]));
// CORRECT: loaded from encrypted file at runtime
import fs from "fs";
const raw = fs.readFileSync(process.env.KEYPAIR_PATH!, "utf-8");
const keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw)));Environment Variable Discipline
# .env.local (NEVER committed to git)
KEYPAIR_PATH=/secure/path/wallet.json
SYNAPSE_API_KEY=sk-...
HELIUS_KEY=...Add to .gitignore:
.env.local
*.json
!package.json
!tsconfig.jsonDelegate Wallets
For automated systems, use a delegate wallet with limited authority. This is the single most important security practice for production services.
Why: Your main keypair is the master key to your agent's entire on-chain presence. If it sits on a server that gets compromised, everything is lost. A delegate wallet is a separate keypair with a small balance, used only for routine operations. If it is compromised, the blast radius is limited to whatever SOL is in that wallet.
- Create a dedicated keypair for the service
- Fund it with only the SOL needed for operations
- Register it as the agent authority
- Monitor its balance and top up programmatically
This limits blast radius: if the delegate is compromised, the attacker can only operate within the agent's scope, not access your main treasury.
Vault Delegation & Permission Bitmask
Vault delegation grants a hot-wallet limited access to vault operations. Permissions are controlled by a bitmask:
| Bit | Permission | Description |
|---|---|---|
0x01 | INSCRIBE | Can inscribe data into sessions |
0x02 | OPEN_SESSION | Can open new sessions |
0x04 | CLOSE_SESSION | Can close sessions |
0x08 | READ | Can read vault data |
// Grant inscribe + read permissions (0x01 | 0x08 = 0x09)
const permissions = 0x01 | 0x08;
await client.vault.addDelegate(vaultPda, hotWallet, permissions);
// Delegated inscriptions include the delegate's signature
await client.vault.inscribeDelegated(sessionPda, data, hash);
// Revoke permissions at any time
await client.vault.revokeDelegate(vaultPda, hotWallet);Delegations have an expiresAt timestamp — after expiry they are automatically invalid. Combine short expiry windows with minimal permission bits for defense in depth.
Authority Model
Every on-chain operation requires a specific signer. Understanding who can do what is essential:
| Operation | Required Signer | Notes |
|---|---|---|
| Register agent | Wallet owner | Creates agent PDA |
| Update/close agent | Wallet owner | Only owner can modify |
| Give feedback | Any wallet | One feedback per reviewer per agent |
| Create escrow | Any wallet (depositor) | Depositor funds the escrow |
| Settle escrow | Agent owner | Agent claims payment |
| Withdraw escrow | Depositor wallet | Depositor reclaims unused funds |
| Init vault | Agent owner | One vault per nonce per agent |
| Inscribe | Agent owner or delegate | Delegate needs INSCRIBE (0x01) |
| Open session | Agent owner or delegate | Delegate needs OPEN_SESSION (0x02) |
| Close session | Agent owner or delegate | Delegate needs CLOSE_SESSION (0x04) |
| Create attestation | Any wallet (attester) | One per attester-subject pair |
| Revoke attestation | Attester wallet | Only attester can revoke |
Encryption
Memory vault inscriptions are encrypted client-side before being stored on-chain. The SDK does not enforce a specific encryption algorithm — you choose your own encrypt(plaintext, key) implementation. Common choices:
- NaCl
secretbox— Recommended. Fast, authenticated, 24-byte nonce. - AES-256-GCM — Also suitable for structured data.
import nacl from "tweetnacl";
function encrypt(plaintext: string, key: Uint8Array): Buffer {
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); // 24 bytes
const message = Buffer.from(plaintext, "utf-8");
const encrypted = nacl.secretbox(message, nonce, key);
// Prepend nonce so decrypt() can extract it
return Buffer.concat([nonce, Buffer.from(encrypted)]);
}
function decrypt(ciphertext: Buffer, key: Uint8Array): string {
const nonce = ciphertext.subarray(0, nacl.secretbox.nonceLength);
const message = ciphertext.subarray(nacl.secretbox.nonceLength);
const decrypted = nacl.secretbox.open(message, nonce, key);
if (!decrypted) throw new Error("Decryption failed");
return Buffer.from(decrypted).toString("utf-8");
}Nonce Rotation
Vaults track a nonceVersion counter. When you rotate:
await client.vault.rotateNonce(vaultPda);
// Increments nonceVersion, updates lastNonceRotation timestamp
// New inscriptions should use the new nonce seedRotation invalidates the old encryption nonce without deleting existing inscriptions. Historical data can still be decrypted if you retain the old key. Rotate periodically (e.g. weekly) as a defense-in-depth measure.
Input Validation
Server-Side Validation
Never trust client input. Validate all parameters before constructing transactions:
import { PublicKey } from "@solana/web3.js";
function validatePublicKey(input: string): PublicKey {
try {
const key = new PublicKey(input);
if (!PublicKey.isOnCurve(key)) {
throw new Error("Key is not on curve");
}
return key;
} catch {
throw new Error(`Invalid public key: ${input}`);
}
}
function validateAmount(input: unknown): number {
const amount = Number(input);
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("Amount must be a positive finite number");
}
if (amount > 1_000_000_000_000) {
throw new Error("Amount exceeds maximum allowed");
}
return amount;
}API Route Guards
export async function POST(request: Request) {
const body = await request.json();
if (!body.agentId || typeof body.agentId !== "string") {
return NextResponse.json(
{ error: "agentId is required" },
{ status: 400 },
);
}
if (body.agentId.length > 64) {
return NextResponse.json(
{ error: "agentId exceeds maximum length" },
{ status: 400 },
);
}
// Proceed with validated input
}Escrow Safety
Set Expiry Times
Always set an expiration on escrows. Without expiry, funds remain locked indefinitely if the agent becomes unresponsive:
const paymentCtx = await client.x402.preparePayment(agentWallet, {
pricePerCall: 10_000,
maxCalls: 100,
deposit: 1_000_000,
expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour max
});Monitor Escrow Balances
Implement alerts for escrow accounts approaching depletion:
const balance = await client.x402.getBalance(agentWallet);
if (balance && balance.callsRemaining < 10) {
console.warn("Escrow nearly depleted:", balance.callsRemaining, "calls remaining");
// Trigger top-up or notification
}Withdraw Promptly
After a service session completes, withdraw remaining funds:
const remaining = await client.x402.getBalance(agentWallet);
if (remaining && remaining.balance.gtn(0)) {
await client.x402.withdrawFunds(agentWallet, remaining.balance);
}Content Integrity
Schema Hash Verification
When publishing tools, the SDK stores a SHA-256 hash of the schema on-chain. The full JSON schema is inscribed into transaction logs (zero ongoing rent), while the PDA stores only the 32-byte hash. Consumers can verify schema integrity by hashing the original schema and comparing it with the on-chain hash:
import { sha256, hashToArray } from "@oobe-protocol-labs/synapse-sap-sdk/utils";
// Fetch the tool descriptor (contains only the hash, not the full schema)
const tool = await client.tools.fetch(agentPda, "myTool");
// The original schema (obtained from TX logs or off-chain cache)
const originalSchema = '{"type":"object","properties":{"token":{"type":"string"}}}';
// Compute the expected hash and compare with on-chain hash
const expectedHash = hashToArray(sha256(originalSchema));
const onChainHash = tool.inputSchemaHash; // number[] (32 bytes)
const isValid = expectedHash.every((byte, i) => byte === onChainHash[i]);
if (!isValid) {
throw new Error("Schema has been tampered with");
}Note: Full schemas are stored in transaction logs via
inscribeSchema(), not in fetachable PDA accounts. To retrieve inscribed schemas, parse the transaction logs of the inscription TX usingclient.parser.
Session Data Integrity
For high-value sessions, hash conversation data before writing:
import { createHash } from "crypto";
function hashEntry(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
const entry = "Agent: Transfer approved. TX: 4xK9...";
const hash = hashEntry(entry);
await client.session.write(ctx, `${entry}|hash:${hash}`);Production Security Checklist
- Main keypair stored in encrypted vault (not in environment variables)
- Delegate wallets used for automated operations
- All user inputs validated server-side before transaction construction
- Escrow accounts always have expiration timestamps
- Schema hashes verified before trusting tool descriptors
- API routes require authentication (API key, JWT, or session tokens)
- Rate limiting applied to all public-facing endpoints
- Transaction simulation enabled (preflight checks) for all writes
- Monitoring and alerts for unusual account activity
- Regular key rotation for delegate wallets