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:

  1. Create: Build transaction with instructions
  2. Add Recent Blockhash: Fetch recent blockhash from RPC (acts as TTL)
  3. Serialize Message: Convert message to bytes for signing
  4. Sign: Create Ed25519 signature of serialized message
  5. Submit: Send to Solana RPC node
  6. Process: Validator executes and commits
  7. 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

AspectServer-SideClient-Side
Who SignsPlatform serverUser's device
Private Key LocationServer (HSM/KMS)User's device/hardware
User InteractionNone requiredMust click/confirm
Transaction SpeedInstantWaits for user
Security ModelTrust platformTrustless
ComplexitySimpleComplex
Best ForGaming, rewardsDeFi, 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

Instant Execution

No waiting for user interaction, transactions execute immediately

Simple UX

Users don't need to understand signing or approve each transaction

Batch Operations

Easy to batch multiple operations efficiently

Gasless Transactions

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

True Ownership

User has complete control, platform never sees private keys

No Platform Risk

Platform breach doesn't compromise user funds

Transparent

User reviews and approves every transaction

Regulatory Clarity

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

For Server-Side Transactions
  • Store keys in HSM or cloud KMS
  • Implement rate limiting
  • Monitor for suspicious activity
  • Set transaction amount limits
  • Maintain audit logs
For Client-Side Transactions
  • 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;
}

Next Steps

Transaction Flows | Cilantro Smart Wallet Docs | Cilantro Smart Wallet