Tenzro Testnet is live. Get testnet TNZO

Batch Processing

Batch processing enables atomic execution of multiple settlements in a single transaction. The batch processor ensures all-or-nothing semantics: either all settlements in a batch succeed, or they all fail and state is rolled back. This is critical for complex payment flows involving multiple parties, escrow releases, and fee distributions.

Overview

The batch processor provides transactional guarantees for multi-party settlements. Use cases include:

  • Marketplace escrow release — Simultaneous payment to seller, platform fee to treasury, and arbiter fee
  • Multi-model inference — Parallel payment to multiple AI providers for ensemble predictions
  • Reward distribution — Epoch-based validator rewards paid atomically to all validators
  • Cross-chain settlement — Coordinated settlement across multiple chains via bridge adapters
  • Agent swarm payments — Payment to multiple agents for collaborative task completion

Key Guarantees:

  • Atomicity: All settlements succeed or all fail
  • Consistency: Balance snapshots ensure rollback on failure
  • Isolation: Batch execution locks affected accounts
  • Durability: Successful batches synced to RocksDB with fsync

Batch Creation

A batch is a collection of SettlementRequest objects grouped under a unique batch ID. Each request in the batch can be a different settlement type (immediate, escrow, micropayment channel) and can involve different assets (TNZO, USDC, ETH).

use tenzro_settlement::{BatchProcessor, SettlementRequest, SettlementBatch};
use tenzro_types::{Address, Hash};

// Initialize batch processor
let processor = BatchProcessor::new(
    settlement_engine.clone(),
    fee_collector.clone(),
);

// Create batch of settlements
let batch_id = Hash::from_hex("0xbatch123...")?;
let requests = vec![
    // Payment to primary model provider
    SettlementRequest {
        settlement_id: Hash::from_hex("0xsettlement1...")?,
        from: Address::from_hex("0xpayer...")?,
        to: Address::from_hex("0xprovider1...")?,
        amount: 800_000_000_000_000_000, // 0.8 TNZO
        asset: "TNZO".to_string(),
        proof: Some(ServiceProof::TeeAttestation { /* ... */ }),
        metadata: serde_json::json!({
            "model": "gpt-4",
            "inference_id": "inf_abc"
        }),
    },

    // Payment to secondary model provider
    SettlementRequest {
        settlement_id: Hash::from_hex("0xsettlement2...")?,
        from: Address::from_hex("0xpayer...")?,
        to: Address::from_hex("0xprovider2...")?,
        amount: 200_000_000_000_000_000, // 0.2 TNZO
        asset: "TNZO".to_string(),
        proof: Some(ServiceProof::TeeAttestation { /* ... */ }),
        metadata: serde_json::json!({
            "model": "claude-3.5-sonnet",
            "inference_id": "inf_xyz"
        }),
    },
];

let batch = SettlementBatch {
    batch_id: batch_id.clone(),
    requests,
    status: BatchStatus::Pending,
};

// Submit batch for atomic execution
processor.process_batch(batch).await?;

Atomic Execution

The batch processor guarantees atomic execution through a three-phase protocol:

Phase 1: Snapshot

Before processing any settlements, the processor creates a snapshot of all account balances that will be modified. This enables full rollback if any settlement fails.

// Snapshot phase (internal to BatchProcessor)
let mut balance_snapshots = HashMap::new();

for request in &batch.requests {
    // Snapshot payer balance
    let payer_balance = account_store
        .get_balance(&request.from, &request.asset)
        .await?;
    balance_snapshots.insert(
        (request.from.clone(), request.asset.clone()),
        payer_balance
    );

    // Snapshot payee balance
    let payee_balance = account_store
        .get_balance(&request.to, &request.asset)
        .await?;
    balance_snapshots.insert(
        (request.to.clone(), request.asset.clone()),
        payee_balance
    );
}

Phase 2: Execute

Each settlement in the batch is executed sequentially. If any settlement fails (insufficient balance, invalid proof, validation error), execution stops immediately and proceeds to rollback phase.

// Execute phase
let mut receipts = Vec::new();

for request in &batch.requests {
    match settlement_engine.settle(request).await {
        Ok(receipt) => {
            receipts.push(receipt);
        }
        Err(e) => {
            // Settlement failed - trigger rollback
            error!("Settlement {} failed: {}", request.settlement_id.to_hex(), e);

            // Restore all balances from snapshot
            for ((address, asset), balance) in balance_snapshots {
                account_store.set_balance(&address, &asset, balance).await?;
            }

            // Clear all receipts
            receipts.clear();

            // Mark batch as failed
            batch.status = BatchStatus::Failed {
                error: e.to_string(),
            };

            return Err(e);
        }
    }
}

Phase 3: Finalize

If all settlements succeed, the batch is finalized by:

  1. Collecting network fees from all settlements
  2. Persisting settlement receipts to RocksDB
  3. Syncing balance changes with fsync for durability
  4. Updating batch status to Completed
  5. Broadcasting batch completion event to gossipsub
// Finalize phase
if receipts.len() == batch.requests.len() {
    // All settlements succeeded

    // Collect fees
    for receipt in &receipts {
        let fee = fee_collector.calculate_fee(receipt.amount);
        fee_collector.collect(
            &receipt.to,
            &receipt.asset,
            fee
        ).await?;
    }

    // Persist receipts to storage
    for receipt in &receipts {
        storage.store_receipt(receipt).await?;
    }

    // Sync to disk with fsync
    storage.sync().await?;

    // Update batch status
    batch.status = BatchStatus::Completed {
        receipts: receipts.clone(),
    };

    // Broadcast completion event
    gossip.publish("tenzro/settlements/1.0.0", &batch).await?;
}

Implementation:

The batch processor correctly snapshots balances before processing, stops on first failure, restores all balance changes on rollback, clears receipts, and updates batch status to Failed with error message. Atomicity is ensured.

All-or-Nothing Semantics

The batch processor guarantees that either all settlements in a batch complete successfully, or none of them do. This prevents partial execution states that could lead to fund loss or inconsistent accounting.

Example: Marketplace Escrow Release

Consider a marketplace where a buyer purchases a digital asset from a seller through an escrow. When the seller delivers the asset, the escrow is released in a batch containing three settlements:

let batch_requests = vec![
    // 1. Payment to seller (85% of escrow)
    SettlementRequest {
        settlement_id: Hash::from_hex("0xpay_seller...")?,
        from: Address::from_hex("0xescrow...")?,
        to: Address::from_hex("0xseller...")?,
        amount: 850_000_000_000_000_000, // 0.85 TNZO
        asset: "TNZO".to_string(),
        proof: Some(ServiceProof::MultiParty {
            signers: vec![buyer_addr, arbiter_addr],
            signatures: vec![buyer_sig, arbiter_sig],
            threshold: 2,
        }),
        metadata: serde_json::json!({
            "type": "seller_payment",
            "asset_id": "nft_12345"
        }),
    },

    // 2. Platform fee to marketplace (10% of escrow)
    SettlementRequest {
        settlement_id: Hash::from_hex("0xplatform_fee...")?,
        from: Address::from_hex("0xescrow...")?,
        to: Address::from_hex("0xmarketplace...")?,
        amount: 100_000_000_000_000_000, // 0.1 TNZO
        asset: "TNZO".to_string(),
        proof: Some(ServiceProof::Cryptographic {
            algorithm: "ed25519".to_string(),
            signature: platform_sig,
            public_key: platform_pubkey,
        }),
        metadata: serde_json::json!({
            "type": "platform_fee",
            "fee_percentage": 10
        }),
    },

    // 3. Arbiter fee (5% of escrow)
    SettlementRequest {
        settlement_id: Hash::from_hex("0xarbiter_fee...")?,
        from: Address::from_hex("0xescrow...")?,
        to: Address::from_hex("0xarbiter...")?,
        amount: 50_000_000_000_000_000, // 0.05 TNZO
        asset: "TNZO".to_string(),
        proof: Some(ServiceProof::Cryptographic {
            algorithm: "ed25519".to_string(),
            signature: arbiter_sig,
            public_key: arbiter_pubkey,
        }),
        metadata: serde_json::json!({
            "type": "arbiter_fee"
        }),
    },
];

let batch = SettlementBatch {
    batch_id: Hash::from_hex("0xmarketplace_batch...")?,
    requests: batch_requests,
    status: BatchStatus::Pending,
};

// If any of the three payments fail (e.g., arbiter address invalid),
// all three are rolled back - seller doesn't get paid either
processor.process_batch(batch).await?;

Without atomic batch processing, a failure in payment 3 (arbiter fee) could leave payments 1 and 2 (seller and platform) in a completed state, with no way to reverse them. The batch processor prevents this by rolling back the entire batch.

FeeCollector

The FeeCollector is responsible for calculating, collecting, and routing network fees to the treasury. All settlements (individual and batch) incur a network fee of 0.5% (50 basis points) by default.

Fee Calculation

use tenzro_settlement::FeeCollector;

let collector = FeeCollector::new(
    treasury_addr,
    50, // 0.5% = 50 basis points
);

// Calculate fee for settlement
let settlement_amount = 1_000_000_000_000_000_000; // 1 TNZO
let fee = collector.calculate_fee(settlement_amount);
// fee = 5_000_000_000_000_000 (0.005 TNZO)

let net_amount = settlement_amount - fee;
// net_amount = 995_000_000_000_000_000 (0.995 TNZO)

Fee Collection

Fees are deducted from the settlement amount before crediting the recipient. The FeeCollector maintains per-asset fee accumulators and periodically flushes them to the network treasury.

// Fee collection (internal to SettlementEngine)
pub async fn settle(&self, request: &SettlementRequest) -> Result<Receipt> {
    // Calculate network fee
    let fee = self.fee_collector.calculate_fee(request.amount);
    let net_amount = request.amount - fee;

    // Debit full amount from payer
    self.account_store.debit(
        &request.from,
        &request.asset,
        request.amount
    ).await?;

    // Credit net amount to payee
    self.account_store.credit(
        &request.to,
        &request.asset,
        net_amount
    ).await?;

    // Collect fee to treasury
    self.fee_collector.collect(
        &request.to, // Fee paid by recipient
        &request.asset,
        fee
    ).await?;

    Ok(Receipt {
        settlement_id: request.settlement_id.clone(),
        from: request.from.clone(),
        to: request.to.clone(),
        amount: net_amount,
        fee,
        asset: request.asset.clone(),
        timestamp: SystemTime::now(),
    })
}

Treasury Routing

Collected fees are routed to the NetworkTreasury contract, which manages multi-asset reserves and multisig withdrawals. Fees fund validator rewards, protocol development, ecosystem grants, and network operations.

use tenzro_token::NetworkTreasury;

// Initialize treasury with multisig (3-of-5 threshold)
let treasury = NetworkTreasury::new(
    vec![
        Address::from_hex("0xkeeper1...")?,
        Address::from_hex("0xkeeper2...")?,
        Address::from_hex("0xkeeper3...")?,
        Address::from_hex("0xkeeper4...")?,
        Address::from_hex("0xkeeper5...")?,
    ],
    3, // Threshold
);

// FeeCollector periodically flushes fees to treasury
pub async fn flush_to_treasury(&self) -> Result<()> {
    let accumulated_fees = self.accumulated_fees.lock().await;

    for (asset, amount) in accumulated_fees.iter() {
        if *amount > 0 {
            // Transfer fees to treasury
            self.account_store.transfer(
                &self.treasury_addr,
                asset,
                *amount
            ).await?;

            info!(
                "Flushed {} {} in fees to treasury",
                amount,
                asset
            );
        }
    }

    // Clear accumulators
    accumulated_fees.clear();
    Ok(())
}

Network Fee (0.5%)

The default network fee of 0.5% (50 basis points) applies to all settlement types:

  • Immediate settlements — Fee deducted before crediting recipient
  • Escrow releases — Fee calculated on escrowed amount, paid on release
  • Micropayment channels — Fee calculated on channel close, deducted from final settlement
  • Cross-chain transfers — Fee on both source and destination chains (0.5% each = 1% total)

Fee Examples:

  • 1 TNZO settlement → 0.005 TNZO fee (0.5%)
  • 100 TNZO settlement → 0.5 TNZO fee (0.5%)
  • 10,000 TNZO settlement → 50 TNZO fee (0.5%)

Fee Governance

The network fee percentage is a governable parameter that can be adjusted through on-chain proposals. Validators vote on fee changes, which take effect after a time delay to allow users to adjust.

use tenzro_token::GovernanceEngine;

// Submit proposal to change network fee
let governance = GovernanceEngine::new();
let proposal = governance.submit_proposal(
    "Reduce Network Fee to 0.3%",
    "Proposal to reduce settlement fee from 0.5% to 0.3%",
    serde_json::json!({
        "type": "parameter_change",
        "parameter": "network_fee_bps",
        "current_value": 50,
        "proposed_value": 30,
        "activation_delay": 604800, // 7 days
    }),
).await?;

// Validators vote
for validator in validators {
    governance.vote(proposal.id, validator.address, true).await?;
}

// After quorum reached and delay passed, fee updates automatically

Batch Size Limits

To prevent denial-of-service attacks and ensure reasonable execution time, batches are subject to size limits:

  • Max settlements per batch: 1,000
  • Max total value per batch: 10,000,000 TNZO
  • Max unique accounts per batch: 10,000
  • Max batch execution time: 30 seconds
// Batch validation
pub async fn process_batch(&self, mut batch: SettlementBatch) -> Result<()> {
    // Check batch size limit
    if batch.requests.len() > 1000 {
        return Err(SettlementError::BatchTooLarge {
            size: batch.requests.len(),
            max: 1000,
        });
    }

    // Check total value limit
    let total_value: u64 = batch.requests
        .iter()
        .filter(|r| r.asset == "TNZO")
        .map(|r| r.amount)
        .sum();

    if total_value > 10_000_000 * 10u64.pow(18) {
        return Err(SettlementError::BatchValueTooHigh {
            value: total_value,
            max: 10_000_000 * 10u64.pow(18),
        });
    }

    // Set execution timeout
    let timeout = Duration::from_secs(30);
    tokio::time::timeout(timeout, self.execute_batch(&mut batch)).await??;

    Ok(())
}

Error Handling

Batch processing can fail for several reasons. The BatchProcessor provides detailed error information and partial execution state for debugging.

Common Failure Modes

match processor.process_batch(batch).await {
    Ok(_) => {
        info!("Batch completed successfully");
    }
    Err(SettlementError::InsufficientBalance { address, asset, required, available }) => {
        error!(
            "Settlement failed: {} needs {} {} but only has {}",
            address.to_hex(),
            required,
            asset,
            available
        );
        // All settlements in batch rolled back
    }
    Err(SettlementError::ProofVerificationFailed { settlement_id, reason }) => {
        error!(
            "Proof verification failed for {}: {}",
            settlement_id.to_hex(),
            reason
        );
        // All settlements in batch rolled back
    }
    Err(SettlementError::BatchTooLarge { size, max }) => {
        error!("Batch contains {} settlements, max is {}", size, max);
        // Batch rejected before execution
    }
    Err(SettlementError::PartialExecution { completed, failed_at, error }) => {
        error!(
            "Batch failed at settlement {} (completed {}): {}",
            failed_at,
            completed,
            error
        );
        // All settlements rolled back via snapshot restoration
    }
    Err(e) => {
        error!("Batch processing failed: {}", e);
    }
}

Performance Optimization

For large batches, the batch processor employs several optimizations:

  • Balance caching — Account balances loaded once and cached during batch execution
  • Parallel proof verification — ZK proofs and TEE attestations verified in parallel via tokio spawn
  • Write batching — All storage writes buffered and committed in a single RocksDB write batch
  • Lock coarsening — Account locks acquired once per account, not per settlement
// Parallel proof verification
let proof_futures: Vec<_> = batch.requests
    .iter()
    .filter_map(|r| r.proof.as_ref())
    .map(|proof| {
        let verifier = self.proof_verifier.clone();
        tokio::spawn(async move {
            verifier.verify(proof).await
        })
    })
    .collect();

// Wait for all proofs to verify
let results = futures::future::join_all(proof_futures).await;
for result in results {
    result??; // Fail batch if any proof invalid
}

Integration with Settlement Engine

The batch processor wraps the settlement engine and delegates individual settlement execution. This ensures that all settlement logic (proof verification, balance checks, fee collection) is reused for both single and batch settlements.

pub struct BatchProcessor {
    settlement_engine: Arc<SettlementEngine>,
    fee_collector: Arc<FeeCollector>,
    account_store: Arc<dyn AccountStore>,
    storage: Arc<dyn SettlementStore>,
}

impl BatchProcessor {
    pub async fn process_batch(&self, mut batch: SettlementBatch) -> Result<()> {
        // Validate batch
        self.validate_batch(&batch)?;

        // Snapshot balances
        let snapshots = self.snapshot_balances(&batch).await?;

        // Execute settlements sequentially
        let mut receipts = Vec::new();
        for (index, request) in batch.requests.iter().enumerate() {
            match self.settlement_engine.settle(request).await {
                Ok(receipt) => receipts.push(receipt),
                Err(e) => {
                    // Rollback all changes
                    self.restore_snapshots(snapshots).await?;
                    batch.status = BatchStatus::Failed {
                        error: e.to_string(),
                        failed_at: index,
                    };
                    return Err(e);
                }
            }
        }

        // Finalize batch
        batch.status = BatchStatus::Completed { receipts };
        self.storage.store_batch(&batch).await?;

        Ok(())
    }
}

Next Steps

Now that you understand batch processing and fee collection, explore: