Tenzro Testnet is live —request testnet TNZO
← Back to Tutorials

Build a Key Custody Application

SecurityAdvanced35 min

Build a key custody application using Tenzro's MPC wallet system. Create 2-of-3 threshold wallets without seed phrases, export and import encrypted keystores protected by Argon2id, rotate keys without changing addresses, set spending limits and policies, create scoped time-limited session keys, and derive addresses for multiple chains from a single wallet.

What You'll Build

  • MPC wallets with 2-of-3 threshold signing (no seed phrase)
  • Encrypted keystore export/import with Argon2id KDF
  • Key rotation that preserves the wallet address
  • Spending limits, daily caps, and recipient whitelists
  • Scoped, time-limited session keys for delegated access
  • Session revocation and active session monitoring
  • Multi-chain key derivation (Ethereum, Solana, Bitcoin)

Security Properties

  • Threshold signing — 2-of-3 Shamir Secret Sharing over GF(256), no single point of failure
  • Argon2id KDF — 64 MB memory, 3 iterations, parallelism 4, resistant to GPU cracking
  • Key zeroization — sensitive key material zeroized on drop via the zeroize crate
  • Signature verification — automatic post-signing verification via tenzro_crypto::signatures::verify()
  • Transaction validation — chain ID, nonce, gas bounds, address format, and data size checks

Prerequisites

[dependencies]
tenzro-sdk = "0.1"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
tracing-subscriber = "0.3"

Step 1: Create MPC Wallets

MPC wallets use 2-of-3 threshold signing. No seed phrase is needed — key shares are auto-provisioned and distributed across key holders:

use tenzro_sdk::{TenzroClient, config::SdkConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = SdkConfig::testnet();
    let client = TenzroClient::connect(config).await?;

    // Create a 2-of-3 MPC threshold wallet
    // No seed phrase needed -- shares are auto-provisioned
    let wallet = client.wallet().create_wallet().await?;

    println!("MPC Wallet Created:");
    println!("  Address:   {}", wallet.address);
    println!("  Threshold: 2-of-3");
    println!("  Key type:  Ed25519");
    println!("  Shares:    3 (distributed across key holders)");
MPC Wallet Created:
  Address:   0x7f3a9c1e4b2d8f3a5e6c7d8e9f0a1b2c3d4e5f6a
  Threshold: 2-of-3
  Key type:  Ed25519
  Shares:    3 (distributed across key holders)

Step 2: Export and Import Encrypted Keystores

Keystores are encrypted with AES-256-GCM using a key derived from your passphrase via Argon2id. The Argon2id parameters (64 MB memory, 3 iterations) make brute-force attacks impractical:

// Export encrypted keystore
// Uses Argon2id KDF: 64MB memory, 3 iterations, parallelism 4
let keystore = client.wallet().export_keystore(
    &wallet.address,
    "my-strong-passphrase-here",
).await?;

println!("Keystore exported:");
println!("  Format:    Encrypted JSON");
println!("  KDF:       Argon2id");
println!("  Memory:    64 MB");
println!("  Iterations: 3");
println!("  Size:      {} bytes", keystore.len());

// Save to file
std::fs::write("wallet-backup.json", &keystore)?;
println!("  Saved to:  wallet-backup.json");
// Import wallet from encrypted keystore
let imported = client.wallet().import_keystore(
    &std::fs::read_to_string("wallet-backup.json")?,
    "my-strong-passphrase-here",
).await?;

println!("Wallet imported: {}", imported.address);
assert_eq!(imported.address, wallet.address);

CLI equivalent:

# CLI keystore operations
# Create wallet
tenzro wallet create

# Export keystore (prompts for passphrase)
tenzro wallet export --address 0x7f3a...5f6a --output wallet-backup.json

# Import keystore
tenzro wallet import --keystore wallet-backup.json

# List wallets
tenzro wallet list

# Output:
# Wallets:
#   0x7f3a...5f6a  Ed25519  2-of-3  Balance: 100.00 TNZO

Step 3: Key Rotation

Key rotation generates fresh Shamir shares without changing the wallet address. If a share is compromised, rotation invalidates all old shares:

// Key rotation: generate new shares without changing the address
// This is critical for custody apps -- if a share is compromised,
// rotation invalidates the old shares and creates new ones
let rotation_result = client.wallet().rotate_keys(&wallet.address).await?;

println!("Key Rotation Complete:");
println!("  Address:    {} (unchanged)", rotation_result.address);
println!("  Old shares: invalidated");
println!("  New shares: 3 fresh shares generated");
println!("  Threshold:  still 2-of-3");

// The address stays the same because rotation generates new
// Shamir Secret Sharing shares over GF(256) while preserving
// the underlying key. All old shares are immediately invalidated.

How rotation works. Shamir Secret Sharing over GF(256) allows generating new polynomial shares that reconstruct the same secret. The old shares become useless because the polynomial coefficients change while the constant term (the secret, which derives the address) stays the same. This is a standard MPC key refresh operation.

Step 4: Set Spending Limits and Policies

Policies are enforced at the wallet level before transaction signing. A transaction that violates any policy is rejected before it reaches the network:

// Set spending limits and policies
// These are enforced at the wallet level before transaction signing
let policy = client.wallet().set_policy(
    &wallet.address,
    WalletPolicy {
        // Per-transaction limit
        max_transaction: 10_000,           // 10,000 TNZO max per tx
        // Daily spending limit
        daily_limit: 50_000,               // 50,000 TNZO per day
        // Whitelist of allowed recipient addresses
        allowed_recipients: vec![
            "0x1234...5678".to_string(),    // treasury
            "0xabcd...ef01".to_string(),    // operations
        ],
        // Require additional approval above threshold
        approval_threshold: 5_000,         // require 2nd approval above 5K
        // Time-based restrictions
        time_restrictions: Some(TimeRestrictions {
            allowed_hours: (9, 21),        // 9 AM to 9 PM UTC only
            allowed_days: vec![1, 2, 3, 4, 5], // weekdays only
        }),
    },
).await?;

println!("Policy set:");
println!("  Max tx:        10,000 TNZO");
println!("  Daily limit:   50,000 TNZO");
println!("  Whitelist:     2 addresses");
println!("  Approval above: 5,000 TNZO");
println!("  Time window:   Mon-Fri 09:00-21:00 UTC");

Step 5: Create Session Keys

Session keys are temporary keys with scoped permissions. They are ideal for delegating limited wallet access to agents, trading bots, or services without exposing the master key shares:

// Create a session key (scoped, time-limited)
// Session keys are temporary keys that can only perform specific actions
// within defined constraints. Perfect for delegating limited access
// to agents or services.
let session = client.wallet().create_session_key(
    &wallet.address,
    SessionKeyConfig {
        // Unique session identifier
        session_id: "agent-trading-session".to_string(),
        // What this key can do
        allowed_operations: vec![
            "transfer".to_string(),
            "bridge".to_string(),
        ],
        // Maximum value per transaction
        max_value_per_tx: 1_000,           // 1,000 TNZO per tx
        // Total budget for the session
        total_budget: 10_000,              // 10,000 TNZO total
        // Session duration
        expires_at: chrono::Utc::now() + chrono::Duration::hours(4),
        // Restrict to specific recipients
        allowed_recipients: Some(vec![
            "0x1234...5678".to_string(),
        ]),
        // Restrict to specific chains
        allowed_chains: Some(vec![
            "tenzro".to_string(),
            "ethereum".to_string(),
        ]),
    },
).await?;

println!("Session Key Created:");
println!("  Session ID:   {}", session.session_id);
println!("  Public Key:   {}", session.public_key);
println!("  Operations:   {:?}", session.allowed_operations);
println!("  Max per tx:   {} TNZO", session.max_value_per_tx);
println!("  Budget:        {} TNZO", session.total_budget);
println!("  Expires:      {}", session.expires_at);
Session Key Created:
  Session ID:   agent-trading-session
  Public Key:   0x8a3f...c4d1
  Operations:   ["transfer", "bridge"]
  Max per tx:   1000 TNZO
  Budget:        10000 TNZO
  Expires:      2026-04-12T18:00:00Z

Using a Session Key

// Use a session key for a transaction
// The session key signs the transaction, and the wallet validates
// against the session constraints before broadcasting
let tx = client.wallet().send_with_session(
    &session.session_id,
    "0x1234...5678",          // recipient (must be in whitelist)
    500,                       // 500 TNZO (under 1K per-tx limit)
).await?;

println!("Transaction sent via session key:");
println!("  Tx hash:     {}", tx.hash);
println!("  Session:     {}", session.session_id);
println!("  Remaining:   {} TNZO budget", session.total_budget - 500);

// Attempt to exceed session limits
let err = client.wallet().send_with_session(
    &session.session_id,
    "0x1234...5678",
    5_000,  // 5,000 TNZO -- exceeds 1K per-tx limit
).await;

match err {
    Err(e) => println!("Blocked: {}", e),  // "exceeds max_value_per_tx"
    Ok(_) => unreachable!(),
}

Step 6: Revoke Sessions

// Revoke a session key
// Immediately invalidates the session -- no further transactions allowed
client.wallet().revoke_session(&wallet.address, &session.session_id).await?;
println!("Session revoked: {}", session.session_id);

// List active sessions
let sessions = client.wallet().list_sessions(&wallet.address).await?;
for s in &sessions {
    println!("  {} -- ops: {:?}, expires: {}, budget: {} TNZO",
        s.session_id, s.allowed_operations, s.expires_at, s.remaining_budget);
}

Step 7: Multi-Chain Key Derivation

Derive addresses for multiple chains from the same MPC wallet. Each chain uses its native curve (secp256k1 for Ethereum/Bitcoin, Ed25519 for Solana/Tenzro):

// Multi-chain key derivation
// Derive addresses for different chains from the same MPC wallet
let eth_address = client.wallet().derive_address(
    &wallet.address,
    "ethereum",
    "secp256k1",  // Ethereum uses secp256k1
).await?;

let sol_address = client.wallet().derive_address(
    &wallet.address,
    "solana",
    "ed25519",    // Solana uses Ed25519
).await?;

let btc_address = client.wallet().derive_address(
    &wallet.address,
    "bitcoin",
    "secp256k1",
).await?;

println!("Multi-Chain Addresses:");
println!("  Tenzro:   {}", wallet.address);
println!("  Ethereum: {}", eth_address);
println!("  Solana:   {}", sol_address);
println!("  Bitcoin:  {}", btc_address);
Multi-Chain Addresses:
  Tenzro:   0x7f3a9c1e4b2d8f3a5e6c7d8e9f0a1b2c3d4e5f6a
  Ethereum: 0x4b2d8f3a5e6c7d8e9f0a1b2c3d4e5f6a7f3a9c1e
  Solana:   7Kj4mR9fBn2xWqYz5Lp8sT3vU6hF1cD0eA9gH2jM4kN
  Bitcoin:  bc1q7f3a9c1e4b2d8f3a5e6c7d8e9f0a1b2c3d

Full Example

Run with:

cargo run --example custody_app
View full source
use tenzro_sdk::{TenzroClient, config::SdkConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();
    let client = TenzroClient::connect(SdkConfig::testnet()).await?;

    // 1. Create MPC wallet
    let wallet = client.wallet().create_wallet().await?;
    println!("Wallet: {}", wallet.address);

    // 2. Export encrypted keystore
    let keystore = client.wallet().export_keystore(
        &wallet.address, "passphrase",
    ).await?;
    std::fs::write("backup.json", &keystore)?;

    // 3. Set spending policy
    client.wallet().set_policy(&wallet.address, WalletPolicy {
        max_transaction: 10_000,
        daily_limit: 50_000,
        allowed_recipients: vec![],
        approval_threshold: 5_000,
        time_restrictions: None,
    }).await?;

    // 4. Create session key
    let session = client.wallet().create_session_key(
        &wallet.address,
        SessionKeyConfig {
            session_id: "demo-session".into(),
            allowed_operations: vec!["transfer".into()],
            max_value_per_tx: 1_000,
            total_budget: 10_000,
            expires_at: chrono::Utc::now() + chrono::Duration::hours(4),
            allowed_recipients: None,
            allowed_chains: None,
        },
    ).await?;

    // 5. Use session key
    let tx = client.wallet().send_with_session(
        &session.session_id, "0x1234...5678", 500,
    ).await?;
    println!("Tx: {}", tx.hash);

    // 6. Revoke session
    client.wallet().revoke_session(&wallet.address, &session.session_id).await?;

    // 7. Derive multi-chain addresses
    let eth = client.wallet().derive_address(&wallet.address, "ethereum", "secp256k1").await?;
    println!("Ethereum: {}", eth);

    // 8. Rotate keys
    client.wallet().rotate_keys(&wallet.address).await?;
    println!("Keys rotated (address unchanged)");

    Ok(())
}

What You Learned

Next Steps