Account Model
A persistent, dust-free state layer powering Sahyadri's nonce-less Flash Transaction architecture.
What is the Account Model?
An Account Model is a blockchain state management approach where each address maintains persistent state including balance, transaction metadata, and replay protection records. Unlike UTXO models that track discrete unspent outputs, accounts track unified global state per address.
┌─────────────────────────────────────┐ │ ACCOUNT MODEL │ ├─────────────────────────────────────┤ │ │ │ csm1sabc123... ─────► Balance: 1000 CSM │ Nonce: 42 (legacy path) │ Flash IDs: 128 (flash path) │ │ │ csm1sxyz789... ─────► Balance: 500 CSM │ Nonce: 15 │ Flash IDs: 42 │ │ └─────────────────────────────────────┘
Every Sahyadri address is an account. When you create a wallet, you get an address with zero balance. As you receive CSM, your balance increases. As you send transactions, replay protection metadata updates. The network globally tracks every account's current state.
Why Sahyadri Uses an Account Model
Sahyadri chose the Account Model over UTXO for three practical reasons that align with its identity-focused architecture.
Native Compatibility with Identity
The Crest Model binds DIDs to controller addresses. If addresses were ephemeral UTXO-style pointers without persistent state, identity controllership would require complex tracking layers. Accounts give every address permanent existence, making them natural anchors for Crest documents.
Simpler Developer Experience
Developers send transactions from one address to another without managing UTXO selection, change outputs, or dust thresholds. This lowers the barrier for wallet builders, SDK integrations, and application developers.
Efficient Storage
An account needs one database entry regardless of how many incoming payments it received. A UTXO model grows with transaction count, requiring users to consolidate inputs periodically. For a network targeting mainstream adoption, accounts reduce operational complexity.
Account Structure
Each account in Sahyadri's state contains fields that together define its complete state. The structure has evolved to support both transaction paths simultaneously.
pub struct AccountState {
// ─── Balance (SHARED by both paths) ───
// CSM token amount in smallest unit (micro-CSM)
// 1 CSM = 100,000,000 kana
pub balance: u64,
// ─── Legacy Nonce Path ───
// Sequential counter, starts at 0
// Increments on every legacy tx from this account
pub nonce: u64,
// Hash of the last legacy tx applied — used for reorg refund safety
pub last_applied_tx_id: [u8; 32],
// ─── Flash Transaction Path ───
// Bounded replay-protection window. Each entry records:
// (flash_id, expiry_daa_score, source_block_hash)
// Auto-pruned after FLASH_PRUNE_WINDOW blocks
pub recent_flashes: Vec<FlashEntry>,
}
pub struct FlashEntry {
pub flash_id: Hash, // Unique tx identifier
pub expiry_daa_score: u64, // Expiry window endpoint
pub block_hash: Hash, // Source block (per-block reorg unwind)
}Address Format
Sahyadri addresses use bech32 encoding with human-readable prefix csm1s. The address derives from the Dilithium3 public key through SHA3-256 hashing, taking the first 20 bytes, followed by bech32 encoding with an error-checking checksum.
// Rust Reference
pub fn address_from_pubkey(pubkey: &DilithiumPublicKey) -> Address {
let hash = sha3_256(&pubkey.to_bytes());
let hash20 = &hash[..20];
bech32_encode("csm1s", hash20)
}
// Output format:
// csm1s9vd6l8yu6cegjs922qm8klrn7jj3d7xgv6jdmp7v9The Evolution: From Nonce to Flash
Traditional account-model blockchains enforce a sequential nonce for every transaction. Each transaction must reference the exact counter of the previous one, forcing transactions from the same account to be processed strictly in order. This introduces bottlenecks, stuck transactions, and race conditions in multi-device wallets.
Sahyadri removes this dependency entirely. Every Flash Transaction carries its own unique identifier, an expiry window, and a cryptographically random salt — allowing multiple transactions from the same account to be processed in parallel, in the same block, without any ordering constraint.
| Aspect | Nonce Path (Legacy) | Flash Path (Sahyadri) |
|---|---|---|
| Replay Protection | Sequential counter | Expiry + flash_id |
| Parallel Txs (same sender) | No — strictly sequential | Yes — order-independent |
| Multi-Device Wallets | Race conditions | No race |
| Offline Signing | Needs chain query | 100-block window |
| Stuck Tx Recovery | Nonce gap blocks all | Independent |
| Reorg Handling | Nonce rollback | Per-block-hash unwind |
Flash Transaction Structure
A Flash Transaction is a compact, self-contained data structure. Every field is fixed-width or length-prefixed to allow zero-copy decoding in Rust.
pub struct FlashTransaction {
pub version: u16, // Protocol version
pub pubkey: Vec<u8>, // 1952 bytes (ML-DSA-65)
pub recipient: Vec<u8>, // 20-byte address hash
pub amount: u64, // kana (10^8 kana = 1 CSM)
pub fee: u64, // kana (min 1000)
pub expiry_daa_score: u64, // current + 90 buffer
pub salt: [u8; 16], // CSPRNG random
pub signature: Vec<u8>, // 3309 bytes (ML-DSA-65)
}One State, Two Paths
A critical architectural principle: Flash Transactions do not create a parallel state. They are a distinct mutation path on the same AccountState, coexisting with the nonce path on the same balance.
┌─────────────────────────────────────────────────────┐
│ SINGLE ACCOUNT STATE │
│ │
│ csm1sXXX: balance: 1000 CSM │
│ nonce: 42 │
│ recent_flashes: [...] │
└─────────────────────────────────────────────────────┘
▲ ▲
│ │
┌───────┴────────┐ ┌───────┴────────┐
│ Nonce Path │ │ Flash Path │
│ │ │ │
│ sender.balance -= amount │
│ recipient.balance += amount │
│ sender.nonce += 1 │
└────────────────┘ └────────────────┘
│
│ sender.balance -= amount
│ recipient.balance += amount
│ sender.recent_flashes.push(...)
└────────────────Transaction Flow
Legacy Nonce Path
┌────────┐ ┌──────────┐ ┌────────┐ ┌────────┐
│ Wallet │──▶│ Mempool │──▶│ Block │──▶│ State │
│ / SDK │ │ │ │Builder │ │ Update │
└────────┘ └──────────┘ └────────┘ └────────┘
│ │ │ │
▼ ▼ ▼ ▼
Build Tx Validate Order by Apply
Sign with Signature Priority Balance
Private Key Nonce Match Fees Change
Balance OK Nonce += 1
last_applied_tx_id = tx.idFlash Transaction Path
┌────────┐ ┌──────────┐ ┌────────┐ ┌────────┐
│ Wallet │──▶│ Mempool │──▶│ Block │──▶│ State │
│ / SDK │ │ │ │Builder │ │ Update │
└────────┘ └──────────┘ └────────┘ └────────┘
│ │ │ │
▼ ▼ ▼ ▼
Build Tx Validate Batch by Apply
Sign with Expiry fee Balance
Private Key Replay Check density Change
Signature recent_flashes.push(
Balance OK flash_id, expiry,
block_hash)The nonce path requires the wallet to fetch the current nonce from chain state before building a transaction. The flash pathrequires the wallet to fetch only the current DAA score for expiry calculation — no sequential dependency.
Validation Order
Each Flash Transaction is validated in a strict, cheapest-first order to minimize wasted computation on invalid inputs.
- Expiry Check. Reject if
current_daa > expiry_daa_score. Single integer comparison — cheapest possible fail-fast. - Fee Minimum. Reject if
fee < 1000 kana. Prevents spam without competing with legitimate traffic. - Replay Detection. Check derived
flash_idagainst sender's boundedrecent_flashesset. O(n) lookup where n is bounded by the pruning window. - Signature Verification. Verify ML-DSA-65 signature against computed sighash. Most CPU-intensive step — runs on dedicated parallel thread pool.
- Balance Check. Ensure sender has sufficient balance to cover
amount + fee. In a batch, balances are aggregated per-sender before this check to prevent double-spend within a block.
Replay Protection: Nonce vs Flash
Nonce Path
Nonce Timeline: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━▶ Time Nonce=0 Nonce=1 Nonce=2 Nonce=3 Nonce=4 │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ [Tx#1] [Tx#2] [Tx#3] [Tx#4] [Tx#5] ✓ Valid: Tx with nonce=3 when stored nonce=3 ✗ Reject: Tx with nonce=2 when stored nonce=3 (REPLAY) ✗ Reject: Tx with nonce=5 when stored nonce=3 (GAP)
Flash Path
Flash ID Timeline (Bounded Window: 100 blocks): ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━▶ Time flash_id=a3f flash_id=b7c flash_id=d9e │ │ │ ▼ ▼ ▼ [Tx#1] [Tx#2] [Tx#3] expiry=100 expiry=200 expiry=300 │ │ │ ▼ ▼ ▼ Stored in Stored in Stored in recent_flashes recent_flashes recent_flashes ✓ Valid: flash_id not in recent_flashes AND current_daa <= expiry ✗ Replay: flash_id already in recent_flashes ✗ Expired: current_daa > expiry_daa_score
The flash path's replay protection is bounded, self-pruning, and order-independent. No global monotonic counter, no chain-wide replay set, no unbounded per-account index.
Reorg Safety
Sahyadri's consensus is a DAG — blocks can be reordered, disconnected, or re-merged as the blue set evolves. Every recent_flashes entry stores the hash of the block that produced it.
Block B reorg hua: │ ▼ For each flash tx in Block B: │ ├── sender.recent_flashes.retain(|e| e.block_hash != B.hash) │ ← Only removes entries from THIS block │ ├── sender.balance += (amount + fee) ← Refund sender │ └── recipient.balance -= amount ← Reverse credit ✓ Parallel txs from other blocks remain untouched ✓ No blind removal — cross-block parallelism preserved ✓ No double-refund possible
last_applied_tx_id before refunding. Flash path uses block_hash matching — cleaner because flash entries are per-block tagged from the start.State Pruning
Replay-tracking state does not grow unbounded. Each flash_id carries the expiry value under which it was accepted. On every block commit, the protocol prunes entries whose expiry has fallen below the current DAA score minus a safety buffer.
pub const FLASH_EXPIRY_BLOCKS: u64 = 100;
pub const FLASH_PRUNE_BUFFER: u64 = 50;
pub const FLASH_PRUNE_WINDOW: u64 = 150;
// Called after each block commit:
pub fn prune_recent_flashes(&mut self, current_daa_score: u64) {
let cutoff = current_daa_score.saturating_sub(FLASH_PRUNE_BUFFER);
self.recent_flashes.retain(|e| e.expiry_daa_score > cutoff);
}With a 100-block expiry and a 50-block safety buffer, the maximum replay window is 150 blocks. After 100 blocks the transaction is invalid anyway; after 150 blocks its replay record is discarded. Storage per account remains bounded by recent activity only.
Batch Processing & Double-Spend Prevention
A single block can contain Flash Transactions from many different senders, processed in parallel. The critical property: same-sender transactions within a block are aggregated before balance validation.
pub fn validate_flash_batch(
&self,
txs: &[FlashTransaction],
current_daa_score: u64,
) -> TxResult<()> {
// 1. Aggregate sender debits
let mut sender_debits: HashMap<ScriptPublicKey, u64> = HashMap::new();
for tx in txs {
let spk = pubkey_to_p2pkh_spk(&tx.pubkey);
*sender_debits.entry(spk).or_insert(0) += tx.amount + tx.fee;
}
// 2. Cumulative balance check (all-or-nothing)
for (spk, total_debit) in sender_debits.iter() {
let account = self.account_store.get(spk)?;
if account.balance < *total_debit {
return Err(TxRuleError::SpendTooHigh(*total_debit, account.balance));
}
}
// 3. Duplicate flash_id within batch
// 4. Per-tx validation (expiry, replay, signature)
...
}Transaction Fees
Fees associated with CSM transactions and applicable protocol operations. Every transaction pays a base fee plus optional priority fee for faster inclusion during congestion periods.
| Component | Cost | Purpose |
|---|---|---|
| Base Fee | 1000 kana (0.00001 CSM) | Network resource compensation |
| Priority Fee | Variable | Faster inclusion during congestion |
| Crest Operations | FREE | Identity is a basic right |
Account Model vs UTXO
| Aspect | Account Model (Sahyadri) | UTXO Model (Bitcoin) |
|---|---|---|
| State | Global per-address | Unspent outputs |
| Address | Permanent existence | Ephemeral (new per tx) |
| Privacy | Lower (balance visible) | Higher (change addresses) |
| Complexity | Simpler for developers | UTXO management required |
| Identity Fit | Natural anchor for Crest | Requires extra layer |
| Storage | Constant per account | Grows with tx count |
| Parallel Txs | Yes (Flash path) | Limited by UTXO graph |
Account ↔ Crest Relationship
┌─────────────────────────────────────────────┐ │ SAHYADRI PROTOCOL │ │ │ │ ┌───────────────┐ ┌───────────────┐ │ │ │ ACCOUNT MODEL │◄──►│ CREST MODEL │ │ │ │ │ │ │ │ │ │ • Balance │ │ • DID │ │ │ │ • Nonce │ │ • Controller │ │ │ │ • Transfers │ │ • Keys │ │ │ │ • Flash State │ │ • Services │ │ │ │ • Fees │ │ │ │ │ └───────┬───────┘ └───────┬───────┘ │ │ │ │ │ │ │ Controller │ │ │ │◄═══════════════════┘ │ │ │ Address links them │ │ ▼ │ │ ┌──────────────────────────────────┐ │ │ │ RocksDB Storage │ │ │ │ │ │ │ │ accounts-store/ || addr → State │ │ │ │ dids-store/ || did → Crest │ │ │ │ dids-addr-index/|| addr → DID │ │ │ └──────────────────────────────────┘ │ └─────────────────────────────────────────────┘
The two models connect through the controller address field. Every Crest stores its controller as a Sahyadri address. That address exists in the Account Model with its own balance. When a Crest operation requires signing, the private key controlling that account address produces the Dilithium3 signature.
This separation means you can have funds without identity (pure EOA sending CSM), identity without funds (free Crest creation), or both linked through the same address. The reverse index enables looking up which DID belongs to any given address, bridging the two models for applications that need complete entity information.
Implementation / Rust Reference
The following code reflects the actual implementation within the Sahyadri consensus layer. These are the real structures and functions currently deployed.
// ════════════════════════════════════════════════════════
// FILE: consensus/src/model/stores/account_store.rs
// Unified Account State (balance + nonce + flash tracking)
// ════════════════════════════════════════════════════════
/// A single flash-tx entry in an account's recent history.
/// Block_hash tracking enables per-block reorg unwind.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FlashEntry {
pub flash_id: Hash,
pub expiry_daa_score: u64,
pub block_hash: Hash,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct AccountState {
pub balance: u64,
pub recent_flashes: Vec<FlashEntry>,
pub nonce: u64,
pub last_applied_tx_id: [u8; 32],
}
// ════════════════════════════════════════════════════════
// FILE: consensus/core/src/tx.rs
// FlashTransaction struct + sighash + flash_id
// ════════════════════════════════════════════════════════
#[derive(Clone, Debug, Serialize, Deserialize,
BorshSerialize, BorshDeserialize, PartialEq, Eq)]
pub struct FlashTransaction {
pub version: u16,
pub pubkey: Vec<u8>,
pub recipient: Vec<u8>,
pub amount: u64,
pub fee: u64,
pub expiry_daa_score: u64,
pub salt: [u8; 16],
pub signature: Vec<u8>,
}
impl FlashTransaction {
/// Deterministic unique ID — used for replay detection
pub fn flash_id(&self) -> Hash {
use sha3::{Digest, Sha3_256};
let mut h = Sha3_256::new();
h.update(b"SAHYADRI_FLASH_ID_V1");
h.update(self.version.to_le_bytes());
h.update(&self.pubkey);
h.update(&self.recipient);
h.update(self.amount.to_le_bytes());
h.update(self.fee.to_le_bytes());
h.update(self.expiry_daa_score.to_le_bytes());
h.update(&self.salt);
Hash::from_bytes(h.finalize().into())
}
}Design Principles
One State, Two Paths
A single AccountState per address. Both nonce-based and Flash transactions mutate the same balance. Flash transactions do not create a parallel state — they are a distinct mutation path with their own replay protection, coexisting with the nonce path on the same balance.
Bounded State
Replay protection is local, temporary, and self-pruning. No global monotonically-growing counter, no chain-wide replay set, no unbounded per-account index. The cost of accepting a transaction is bounded and predictable.
Reorg-Correct
Every applied flash transaction is tagged with its source block. Reorgs unwind only the affected entries. Cross-block parallelism is preserved through reorgs without any replay or state corruption.
Quantum-Resistant
Every transaction is signed with ML-DSA-65 (CRYSTALS-Dilithium3), a NIST-standardized post-quantum signature scheme. The security of the transaction layer does not depend on the eventual hardness of elliptic-curve problems.
Backward Compatible
The legacy nonce path remains fully functional. CEXes, hot wallets, and existing integrations continue to work without modification. Migration to Flash is opt-in via feature flag.
Summary
Sahyadri's Account Model is not a copy of Ethereum's. It is a unified state layer with dual transaction paths — a legacy nonce path for backward compatibility, and a nonce-less Flash Transaction path for parallel execution, multi-device ownership, and quantum-resistant finality.
Combined with Sahyadri's DAG consensus, post-quantum signature scheme, and native identity layer, it forms the state substrate on which Web5 applications can be built without inherited bottlenecks from the previous generation of blockchains.