Creating a Robo / AI Advisor
A robo advisor (or AI advisor) is a programmatic agent that creates proposals and executes trades on behalf of clients. From the on-chain program’s perspective, a robo advisor is identical to a human advisor — it’s just a Solana keypair that signs transactions.
Architecture overview
Section titled “Architecture overview”┌─────────────────────┐ ┌─────────────────────┐│ Trading Strategy │ │ Solana Blockchain ││ (your logic) │────▶│ Beluga Program ││ │ │ ││ - Market signals │ │ - Advisory accounts ││ - Risk management │ │ - Proposals ││ - Position sizing │ │ - Vault custody │└─────────────────────┘ └──────────┬────────────┘ │ polls ┌──────────▼────────────┐ │ Valuation Service │ │ │ │ - Trade extraction │ │ - P&L computation │ │ - Performance metrics │ │ - Leaderboard ranking │ └────────────────────────┘1. Generate a keypair
Section titled “1. Generate a keypair”Your robo advisor needs a Solana keypair. This can be a file-based keypair or any signer that implements the Solana Signer interface.
solana-keygen new -o robo-advisor.jsonsolana address -k robo-advisor.json# → outputs your advisor pubkeyFund this keypair with enough SOL for transaction fees.
2. Derive x25519 encryption keys
Section titled “2. Derive x25519 encryption keys”To encrypt proposals with v2 envelopes (allowing both you and the client to decrypt), derive an x25519 keypair:
import { sha512 } from "@noble/hashes/sha2";import { x25519 } from "@noble/curves/ed25519";
// For a file-based keypair, sign the derivation message directly// using ed25519 from the keypair's secret keyconst DERIVATION_MESSAGE = new TextEncoder().encode("beluga:x25519:v1");
// Hash + clamp to get x25519 key materialconst hash = sha512(signature);const secretKey = clampScalar(hash.slice(0, 32));const publicKey = x25519.getPublicKey(secretKey);
function clampScalar(scalar: Uint8Array): Uint8Array { const clamped = new Uint8Array(scalar); clamped[0] &= 248; clamped[31] &= 127; clamped[31] |= 64; return clamped;}3. Wait for client invitations
Section titled “3. Wait for client invitations”Clients create advisory accounts with your pubkey. Your bot should poll for pending advisory accounts where it is the named advisor:
import { Connection, PublicKey } from "@solana/web3.js";
const PROGRAM_ID = new PublicKey("8A3VegJ9k5mcXr1AeXgGD4Zr6XrAdyWnNiCK1AUTidey");
// Fetch all advisory accounts using getProgramAccounts// Filter by advisor pubkey at offset 40 (after discriminator + owner)const accounts = await connection.getProgramAccounts(PROGRAM_ID, { filters: [ { dataSize: 126 }, // advisory account size { memcmp: { offset: 40, bytes: advisorPubkey.toBase58() } }, ],});4. Accept accounts automatically
Section titled “4. Accept accounts automatically”import { acceptAdvisoryAccount } from "./lib/program/client";
for (const account of pendingAccounts) { await acceptAdvisoryAccount( wallet, connection, account.pubkey, 25, // fee: 25 bps = 0.25% sendTransaction, );}Interacting with the contract
Section titled “Interacting with the contract”Proposal creation flow
Section titled “Proposal creation flow”The core loop of a robo advisor:
import { createProposal } from "./lib/program/client";import { encryptPayload } from "./lib/program/decrypt";
async function proposeSwap( advisoryAccount: { address: PublicKey; ownerEncryptionKey: Uint8Array; proposalCount: number }, signal: { inputMint: string; outputMint: string; amount: number; pool: string; rationale: string },) { // 1. Build the proposal payload const payload = { rationale: signal.rationale, amount: signal.amount, other_amount_threshold: computeSlippageThreshold(signal), amount_specified_is_input: true, a_to_b: isAToB(signal), pool: signal.pool, token_mint_a: signal.inputMint, token_mint_b: signal.outputMint, pool_type: "cpmm" as const, };
// 2. Encrypt with the owner's x25519 public key const encryptedData = encryptPayload( payload, advisoryAccount.ownerEncryptionKey, advisorX25519Keypair, // v2: both parties can decrypt );
// 3. Submit on-chain const txSig = await createProposal( wallet, connection, advisoryAccount.address, advisoryAccount.proposalCount, encryptedData, sendTransaction, );
return txSig;}Execution after approval
Section titled “Execution after approval”Poll for approved proposals and execute within the deadline:
import { executeProposal } from "./lib/program/client";
// Find approved proposals for your advisory accountsconst proposals = await connection.getProgramAccounts(PROGRAM_ID, { filters: [ { memcmp: { offset: 0, bytes: PROPOSAL_DISCRIMINATOR_B58 } }, { memcmp: { offset: 40, bytes: advisorPubkey.toBase58() } }, ],});
for (const proposal of approvedProposals) { const parsed = parseProposal(proposal.account.data);
// Check execution deadline hasn't passed if (Date.now() / 1000 > parsed.executionDeadline) continue;
await executeProposal(wallet, connection, sendTransaction, { address: proposal.pubkey.toBase58(), advisoryAccount: parsed.advisoryAccount, owner: parsed.owner, pool: parsed.pool, tokenMintA: parsed.tokenMintA, tokenMintB: parsed.tokenMintB, aToB: parsed.aToB, });}Fee deduction on execution
Section titled “Fee deduction on execution”When a proposal is executed, fees are deducted from the input amount before the swap:
swap_amount = input_amount - program_fee - advisor_fee
program_fee = (input_amount * program_fee_bps) / 10_000advisor_fee = (input_amount * advisor_fee_bps) / 10_000Fees are transferred to:
- Program fee: Admin’s ATA for the input token
- Advisor fee: Your ATA for the input token (created automatically during execution if needed)
Constraints and limits
Section titled “Constraints and limits”| Constraint | Value |
|---|---|
| Max encrypted payload | 512 bytes |
| Max advisor fee | 100 bps (1%) |
| Min execution window | 60 seconds |
| Max execution window | 24 hours |
| Proposals per account | Unlimited (sequential proposal_count) |
| Active proposals | Tracked in active_proposals (u16) |
How performance is recorded
Section titled “How performance is recorded”The off-chain valuation service automatically tracks your advisor performance. No registration or API calls are needed — it discovers advisors by polling on-chain state.
Data ingestion
Section titled “Data ingestion”The valuation service runs a continuous poller that:
- Scans advisory accounts — discovers all accounts and their advisors via
getProgramAccounts - Tracks proposals — monitors proposal status transitions (pending -> approved -> executed)
- Extracts trade data — for executed proposals, fetches the transaction signature and parses pre/post token balances to determine actual swap output
- Resolves prices — fetches historical token prices from Raydium API at the time of execution
- Computes P&L — calculates realized profit/loss for each trade:
input_value_usd = swap_input_amount * input_price_usdoutput_value_usd = swap_output_amount * output_price_usdpnl_usd = output_value_usd - input_value_usdStored trade record
Section titled “Stored trade record”Each executed proposal generates a trade record:
| Field | Description |
|---|---|
inputAmountRaw | Total input before fees |
programFeeRaw | Protocol fee deducted |
advisorFeeRaw | Your fee deducted |
swapInputRaw | Actual amount sent to AMM |
swapOutputRaw | Amount received from AMM |
inputPriceUsd / outputPriceUsd | Prices at execution time |
pnlUsd | Realized P&L |
Advisor metrics
Section titled “Advisor metrics”The service computes hourly snapshots with these metrics:
| Metric | Formula |
|---|---|
| Win rate | winning_trades / total_trades |
| Total return | total_pnl_usd / total_volume_usd |
| Sharpe ratio | mean_return / stddev_return (requires 5+ trades) |
| Total volume | Sum of input_value_usd across all trades |
| Total P&L | Sum of pnl_usd across all trades |
| Total fees earned | Sum of advisor fees in USD |
| AUM | Aggregate vault balances for your clients |
| Client count | Number of active advisory accounts |
| Valuation score | Composite score used for leaderboard ranking |
Querying your performance
Section titled “Querying your performance”# All metricscurl https://valuation-production-2919.up.railway.app/api/v1/advisors/<your_pubkey>
# Trade historycurl https://valuation-production-2919.up.railway.app/api/v1/advisors/<your_pubkey>/trades
# Time-series snapshotscurl https://valuation-production-2919.up.railway.app/api/v1/advisors/<your_pubkey>/performance
# Leaderboardcurl https://valuation-production-2919.up.railway.app/api/v1/leaderboardSetting a display name
Section titled “Setting a display name”curl -X POST https://valuation-production-2919.up.railway.app/api/v1/advisors/<your_pubkey>/name \ -H "Content-Type: application/json" \ -d '{"displayName": "My Robo Advisor"}'Example: minimal robo advisor loop
Section titled “Example: minimal robo advisor loop”async function run() { // 1. Accept any pending invitations const pendingAccounts = await findPendingAccounts(advisorPubkey); for (const acct of pendingAccounts) { await acceptAdvisoryAccount(wallet, connection, acct.pubkey, 25, sendTx); }
// 2. For each active client, check if we should propose const activeAccounts = await findActiveAccounts(advisorPubkey); for (const acct of activeAccounts) { const signal = await strategy.evaluate(acct); if (signal) { await proposeSwap(acct, signal); } }
// 3. Execute any approved proposals const approved = await findApprovedProposals(advisorPubkey); for (const proposal of approved) { if (withinDeadline(proposal)) { await executeProposal(wallet, connection, sendTx, proposal); } }
// 4. Clean up terminal proposals to reclaim rent const terminal = await findTerminalProposals(advisorPubkey); for (const proposal of terminal) { await closeProposal(wallet, connection, proposal.advisoryAccount, proposal.address, sendTx); }}
// Run every N secondssetInterval(run, 30_000);