Transaction Flows
Understanding server-side and client-side transaction patterns for Cilantro Smart Wallets
Transaction Flows
There are two fundamentally different approaches to sending transactions with Cilantro Smart Wallets, each with different security, UX, and technical implications.
Understanding Solana Transactions
Before diving into the flows, let's understand what a Solana transaction actually is:
Solana Transaction Structure:
Transaction {
signatures: [Signature], // 64 bytes each, initially zeros
message: Message {
header: MessageHeader {
numRequiredSignatures: u8, // Number of signatures required
numReadonlySignedAccounts: u8,
numReadonlyUnsignedAccounts: u8
},
accountKeys: [PublicKey], // All accounts involved
recentBlockhash: Hash, // Recent blockhash (expires in ~60s)
instructions: [Instruction] // Program instructions to execute
}
}
Transaction Lifecycle:
- Create: Build transaction with instructions
- Add Recent Blockhash: Fetch recent blockhash from RPC (acts as TTL)
- Serialize Message: Convert message to bytes for signing
- Sign: Create Ed25519 signature of serialized message
- Submit: Send to Solana RPC node
- Process: Validator executes and commits
- Confirm: Transaction reaches finalized state
Transaction Size Limits:
- Max size: 1232 bytes (Solana packet limit)
- Max accounts: ~35 (depends on instruction data)
- Max instructions: ~10-15 (depends on complexity)
- Recent blockhash: Valid for ~60 seconds
Flow Comparison
| Aspect | Server-Side | Client-Side |
|---|---|---|
| Who Signs | Platform server | User's device |
| Private Key Location | Server (HSM/KMS) | User's device/hardware |
| User Interaction | None required | Must click/confirm |
| Transaction Speed | Instant | Waits for user |
| Security Model | Trust platform | Trustless |
| Complexity | Simple | Complex |
| Best For | Gaming, rewards | DeFi, high-value |
1. Server-Side Transaction (Platform Signs)
Use Cases:
- Platform-controlled (custodial) wallets
- API key signers
- Automated operations
- Gaming rewards
- Instant transactions
How It Works
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Client │ │ Platform │ │ Solana │
│ (User) │ │ Server │ │ RPC │
└────┬─────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ 1. Request TX │ │
│ {to, amount, ...} │ │
│ ──────────────────> │ │
│ │ │
│ │ 2. Get blockhash │
│ │ ──────────────────> │
│ │ <────────────────── │
│ │ │
│ │ 3. Build TX │
│ │ 4. Load priv key │
│ │ 5. Sign TX │
│ │ │
│ │ 6. Submit signed TX │
│ │ ──────────────────> │
│ │ │
│ │ 7. TX signature │
│ │ <────────────────── │
│ │ │
│ 8. Return signature │ │
│ <────────────────── │ │
│ │ │
Send SOL (Server-Side)
import { sendSOL } from 'cilantro-sdk/wallet';
const result = await sendSOL('wallet-id', {
recipientAddress: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
amountLamports: 1000000000, // 1 SOL
memo: 'Payment for services', // Optional
gasless: false // Optional: true for gasless transactions
});
console.log('Transaction sent:', result.data.signature);
console.log('View on explorer:', `https://solscan.io/tx/${result.data.signature}`);
Send SPL Token (Server-Side)
import { sendSPL } from 'cilantro-sdk/wallet';
const result = await sendSPL('wallet-id', {
recipientAddress: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
tokenMintAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
amount: 1000000, // 1 USDC (6 decimals)
decimals: 6,
memo: 'Token payment',
gasless: false
});
console.log('Token sent:', result.data.signature);
Send Custom Transaction (Server-Side)
import { sendTransaction } from 'cilantro-sdk/wallet';
const result = await sendTransaction('wallet-id', {
targetProgramId: 'programId123...',
instructionData: 'base64EncodedInstructionData',
accounts: [
{
pubkey: 'accountPublicKey1',
isSigner: true,
isWritable: true
},
{
pubkey: 'accountPublicKey2',
isSigner: false,
isWritable: false
}
]
});
console.log('Custom transaction sent:', result.data.signature);
Advantages of Server-Side
No waiting for user interaction, transactions execute immediately
Users don't need to understand signing or approve each transaction
Easy to batch multiple operations efficiently
Platform can pay all transaction fees transparently
2. Client-Side Signing (User Signs)
For user-controlled wallets where the user must sign the transaction on their device.
Use Cases:
- User-controlled (non-custodial) wallets
- Email/phone/passkey signers
- DeFi applications
- High-value transactions
- Maximum security
How It Works
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Client │ │ Platform │ │ Solana │
│ (User) │ │ Server │ │ RPC │
└────┬─────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ 1. Request TX prep │ │
│ {to, amount, ...} │ │
│ ──────────────────> │ │
│ │ │
│ │ 2. Get blockhash │
│ │ ──────────────────> │
│ │ <────────────────── │
│ │ │
│ │ 3. Build unsigned TX│
│ │ │
│ 4. Unsigned TX │ │
│ (serialized) │ │
│ <────────────────── │ │
│ │ │
│ 5. User reviews TX │ │
│ 6. Derive keys │ │
│ 7. Sign locally │ │
│ │ │
│ 8. Submit signed TX │ │
│ ──────────────────> │ │
│ │ │
│ │ 9. Forward to RPC │
│ │ ──────────────────> │
│ │ │
│ │ 10. TX signature │
│ │ <────────────────── │
│ │ │
│ 11. Return result │ │
│ <────────────────── │ │
│ │ │
Step 1: Prepare Transaction
import { prepareTransaction } from 'cilantro-sdk/wallet';
import { getEmailSignerKeypair, createLocalStorageAdapter } from 'cilantro-sdk/helpers';
// Get the signer's keypair
const storage = createLocalStorageAdapter();
const keypair = await getEmailSignerKeypair(
'wallet-id',
'signer-id',
{ deviceKeyManager: storage }
);
// Prepare transaction (unsigned)
const preparedTx = await prepareTransaction('wallet-id', {
type: 'SEND_SOL',
signerPubkey: Buffer.from(keypair.publicKey).toString('base64'), // Base64 encoded
sendSolParams: {
recipientAddress: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
amountLamports: 1000000000 // 1 SOL
}
});
console.log('Transaction prepared:', preparedTx.data);
// Returns: { serializedTransaction: "base64...", requiresSignature: true }
Step 2: Sign Transaction
import { Transaction } from '@solana/web3.js';
import { sign as ed25519Sign } from '@noble/ed25519';
// Deserialize transaction
const transaction = Transaction.from(
Buffer.from(preparedTx.data.serializedTransaction, 'base64')
);
// Sign with keypair
async function signTransaction(
transaction: Transaction,
keypair: { publicKey: Uint8Array; secretKey: Uint8Array }
): Promise<Uint8Array> {
// Get the message to sign
const message = transaction.serializeMessage();
// Extract private key (first 32 bytes of secretKey)
const privateKey = keypair.secretKey.slice(0, 32);
// Sign using Ed25519
const signature = await ed25519Sign(message, privateKey);
// Add signature to transaction
transaction.addSignature(
{ toBuffer: () => Buffer.from(keypair.publicKey) } as any,
Buffer.from(signature)
);
return signature;
}
// Sign the transaction
await signTransaction(transaction, keypair);
// Serialize signed transaction
const signedTx = transaction.serialize().toString('base64');
Step 3: Submit Signed Transaction
import { submitTransaction } from 'cilantro-sdk/wallet';
const result = await submitTransaction('wallet-id', {
signedTransaction: signedTx
});
console.log('Transaction submitted:', result.data.signature);
console.log('Explorer URL:', `https://solscan.io/tx/${result.data.signature}`);
Complete Client-Side Flow Example
import {
prepareTransaction,
submitTransaction
} from 'cilantro-sdk/wallet';
import {
getEmailSignerKeypair,
createLocalStorageAdapter
} from 'cilantro-sdk/helpers';
import { Transaction } from '@solana/web3.js';
import { sign as ed25519Sign } from '@noble/ed25519';
async function sendSOLClientSide(
walletId: string,
signerId: string,
recipientAddress: string,
amountLamports: number
) {
// Step 1: Get signer keypair
const storage = createLocalStorageAdapter();
const keypair = await getEmailSignerKeypair(walletId, signerId, {
deviceKeyManager: storage
});
// Step 2: Prepare transaction
const prepared = await prepareTransaction(walletId, {
type: 'SEND_SOL',
signerPubkey: Buffer.from(keypair.publicKey).toString('base64'),
sendSolParams: {
recipientAddress,
amountLamports
}
});
// Step 3: Deserialize and sign transaction
const tx = Transaction.from(
Buffer.from(prepared.data.serializedTransaction, 'base64')
);
const message = tx.serializeMessage();
const privateKey = keypair.secretKey.slice(0, 32);
const signature = await ed25519Sign(message, privateKey);
tx.addSignature(
{ toBuffer: () => Buffer.from(keypair.publicKey) } as any,
Buffer.from(signature)
);
const signedTx = tx.serialize().toString('base64');
// Step 4: Submit
const result = await submitTransaction(walletId, {
signedTransaction: signedTx
});
return result.data;
}
// Usage
const txResult = await sendSOLClientSide(
'wallet-123',
'signer-456',
'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
1000000000 // 1 SOL
);
console.log('Transaction signature:', txResult.signature);
Advantages of Client-Side
User has complete control, platform never sees private keys
Platform breach doesn't compromise user funds
User reviews and approves every transaction
Platform is not a custodian, simpler compliance
Transaction Types
SOL Transfer
Server-Side (Platform Signs):
const result = await sendSOL('wallet-id', {
recipientAddress: 'DYw8j...',
amountLamports: 1000000000
});
Client-Side (User Signs):
const prepared = await prepareTransaction('wallet-id', {
type: 'SEND_SOL',
signerPubkey: signerPublicKey,
sendSolParams: {
recipientAddress: 'DYw8j...',
amountLamports: 1000000000
}
});
// Then sign and submit...
SPL Token Transfer
Server-Side (Platform Signs):
const result = await sendSPL('wallet-id', {
recipientAddress: 'DYw8j...',
tokenMintAddress: 'EPjF...',
amount: 1000000,
decimals: 6
});
Client-Side (User Signs):
const prepared = await prepareTransaction('wallet-id', {
type: 'SEND_SPL',
signerPubkey: signerPublicKey,
sendSplParams: {
recipientAddress: 'DYw8j...',
tokenMintAddress: 'EPjF...',
amount: 1000000,
decimals: 6
}
});
// Then sign and submit...
Custom Transaction (CPI)
Server-Side (Platform Signs):
const result = await sendTransaction('wallet-id', {
targetProgramId: 'program123...',
instructionData: 'base64data...',
accounts: [...]
});
Client-Side (User Signs):
const prepared = await prepareTransaction('wallet-id', {
type: 'CUSTOM',
signerPubkey: signerPublicKey,
customParams: {
targetProgramId: 'program123...',
instructionData: 'base64data...',
accounts: [...]
}
});
// Then sign and submit...
Transaction Monitoring
Check Transaction Status
import { getTransactionStatus } from 'cilantro-sdk/transactions';
const status = await getTransactionStatus('signature...');
console.log(status.data.status); // 'pending' | 'confirmed' | 'failed'
console.log(status.data.confirmations);
Wait for Confirmation
async function waitForConfirmation(signature: string, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 2s delay
const status = await getTransactionStatus(signature);
if (status.data.status === 'confirmed') {
return status.data;
} else if (status.data.status === 'failed') {
throw new Error(`Transaction failed: ${status.data.error}`);
}
}
throw new Error('Transaction timeout');
}
// Usage
const result = await sendSOL('wallet-id', {...});
const confirmed = await waitForConfirmation(result.data.signature);
console.log('Transaction confirmed:', confirmed);
Error Handling
Retry Logic with Exponential Backoff
async function sendSOLWithRetry(
walletId: string,
params: any,
maxRetries: number = 3
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt}/${maxRetries}`);
const result = await sendSOL(walletId, params);
const confirmed = await waitForConfirmation(result.data.signature);
return { signature: result.data.signature, ...confirmed };
} catch (error) {
console.error(`Attempt ${attempt} failed:`, error.message);
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
}
// Wait before retry (exponential backoff)
await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
}
}
}
// Usage
try {
const result = await sendSOLWithRetry('wallet-id', {
recipientAddress: 'DYw8j...',
amountLamports: 1000000000
});
console.log('Success:', result);
} catch (error) {
console.error('Transaction failed:', error.message);
}
Best Practices
Security Considerations
- Store keys in HSM or cloud KMS
- Implement rate limiting
- Monitor for suspicious activity
- Set transaction amount limits
- Maintain audit logs
- Show clear transaction previews
- Validate all inputs
- Encrypt device keys at rest
- Implement session timeouts
- Educate users about phishing
Transaction Preview
Preview transactions before sending to show users estimated fees and balance impact.
import { transactionPreview } from 'cilantro-sdk/transactions';
// Preview SOL transfer
const preview = await transactionPreview('wallet-id', {
type: 'SEND_SOL',
sendSolParams: {
recipientAddress: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
amountLamports: 1000000000
}
});
console.log('Estimated fee:', preview.data.estimatedFee);
console.log('Balance after:', preview.data.balanceAfter);
console.log('Risk level:', preview.data.riskLevel);
// Show preview to user before sending
const confirmed = await showTransactionPreview(preview.data);
if (confirmed) {
await sendSOL('wallet-id', { ... });
}
Fee Estimation
Estimate transaction fees before sending.
import { feeEstimate, networkFeeInfo } from 'cilantro-sdk/transactions';
// Get network fee information
const feeInfo = await networkFeeInfo();
console.log('Network congestion:', feeInfo.data.congestionLevel);
console.log('Recommended priority fee:', feeInfo.data.recommendedPriorityFee);
// Estimate specific transaction fee
const estimate = await feeEstimate({
type: 'SOL_TRANSFER',
fromAddress: 'wallet-address',
toAddress: 'recipient-address',
amount: 1.0,
includePriority: true
});
console.log('Base fee:', estimate.data.baseFee);
console.log('Priority fee:', estimate.data.priorityFee);
console.log('Total fee:', estimate.data.totalFee);
Passkey Transaction Signing
Sign transactions using passkeys for hardware-backed security.
import { sendRawPasskeyTransaction } from 'cilantro-sdk/transactions';
import {
startPasskeyAuthentication,
verifyPasskeyAuthentication
} from 'cilantro-sdk/wallet';
import { Transaction } from '@solana/web3.js';
async function sendSOLWithPasskey(
walletId: string,
recipientAddress: string,
amountLamports: number
) {
// Step 1: Authenticate with passkey
const authOptions = await startPasskeyAuthentication(walletId);
const assertion = await navigator.credentials.get({
publicKey: authOptions.data
}) as PublicKeyCredential;
const authResult = await verifyPasskeyAuthentication(walletId, {
response: assertion.response,
credentialId: assertion.id
});
// Step 2: Build transaction
const transaction = new Transaction();
// ... add transfer instruction
// Step 3: Send with passkey signature
const result = await sendRawPasskeyTransaction({
walletId,
transaction: transaction.serialize().toString('base64'),
passkeySignature: authResult.data.signature
});
return result.data;
}
Non-Custodial Transaction Flow
Complete non-custodial flow with email signer.
import {
prepareTransaction,
submitTransaction
} from 'cilantro-sdk/wallet';
import {
getEmailSignerKeypair,
createLocalStorageAdapter
} from 'cilantro-sdk/helpers';
import { Transaction } from '@solana/web3.js';
import { sign as ed25519Sign } from '@noble/ed25519';
async function sendSOLNonCustodial(
walletId: string,
signerId: string,
recipientAddress: string,
amountLamports: number
) {
// Step 1: Get signer keypair
const storage = createLocalStorageAdapter();
const keypair = await getEmailSignerKeypair(walletId, signerId, {
deviceKeyManager: storage
});
// Step 2: Prepare transaction
const prepared = await prepareTransaction(walletId, {
type: 'SEND_SOL',
signerPubkey: Buffer.from(keypair.publicKey).toString('base64'),
sendSolParams: {
recipientAddress,
amountLamports
}
});
// Step 3: Sign transaction locally
const tx = Transaction.from(
Buffer.from(prepared.data.serializedTransaction, 'base64')
);
const message = tx.serializeMessage();
const privateKey = keypair.secretKey.slice(0, 32);
const signature = await ed25519Sign(message, privateKey);
tx.addSignature(
{ toBuffer: () => Buffer.from(keypair.publicKey) } as any,
Buffer.from(signature)
);
// Step 4: Submit signed transaction
const result = await submitTransaction(walletId, {
signedTransaction: tx.serialize().toString('base64')
});
return result.data;
}