Replay Protection
How Sahyadri prevents a signed flash transaction from being applied twice — without a separate nonce registry, without UTXO inputs, and without an ordering assumption.
Overview
Every signed transaction is replayable by default. Once a signature exists, anyone can broadcast it again. Without protection, a signed transfer of 100 CSM could be replayed to transfer 100 more from the same account — a fundamental vulnerability that every blockchain must address.
Traditional blockchains solve this in two ways:
- UTXO chains (Bitcoin): each transaction spends a specific output. A replayed transaction tries to spend an output that no longer exists — the network rejects it.
- Account chains (Ethereum, Cosmos): each transaction carries a monotonically increasing nonce. A replayed transaction carries an old nonce — the network rejects it.
Sahyadri uses neither. Flash transactions are nonce-less and order-independent. Replay protection is provided by a per-account, time-bounded replay window that lives inside the account leaf itself — committed to the SMT alongside balance, atomically.
Why Not Nonces?
Nonces work well in a linear chain. They break down in a DAG for three reasons:
| Problem | Nonce-based (Ethereum) | Sahyadri replay window |
|---|---|---|
| Parallel transactions | Must be serialized by nonce; can't send N in parallel | Any N can be in flight simultaneously |
| DAG ordering | Nonce assumes a strict per-account order | Order does not exist; only the set of applied flash_ids |
| Account recovery | Lose a nonce in a reorg, state gets stuck | Reorg removes only the unwound block's entries; nothing gets stuck |
| Cross-device wallets | Two devices must sync nonces — race conditions | Each device picks a fresh salt; no coordination needed |
Sahyadri's ghostDAG produces parallel blocks. A nonce-based scheme would force per-account serialization, defeating the whole point of DAG parallelism. The replay window was chosen because it fits the DAG model.
Flash ID Derivation
Every flash transaction has a deterministic 32-byte identifier derived from its own contents:
flash_id = SHA3_256(
"SAHYADRI_FLASH_ID_V1" ||
version (u16 LE) ||
pubkey (1952 bytes) ||
recipient (20 bytes) ||
amount (u64 LE) ||
fee (u64 LE) ||
expiry_daa_score (u64 LE) ||
salt (16 bytes)
)Two transactions are the same if and only if their flash_id matches. Because the fields include a salt (16 random bytes chosen by the sender at signing time), two intentional distinct transfers of the same amount to the same recipient never collide.
impl FlashTransaction {
pub fn flash_id(&self) -> sahyadri_hashes::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);
let out: [u8; 32] = h.finalize().into();
sahyadri_hashes::Hash::from_bytes(out)
}
}The salt is signed together with everything else, so an attacker cannot mutate it. If the attacker changes any byte — amount, recipient, salt — the signature fails. This is what makes the flash_id cryptographically binding to the transaction's intent.
Replay Window
Each account keeps a vector of recently applied flash IDs:
pub struct AccountState {
pub balance: u64,
pub recent_flashes: Vec<FlashEntry>,
}
pub struct FlashEntry {
pub flash_id: Hash,
pub expiry_daa_score: u64,
pub block_hash: Hash, // reorg-local, not committed
}When a new flash transaction arrives, the validator checks whether its flash_id is already present in the sender's recent_flashes. If so, the transaction is rejected as a replay.
The vector is bounded by three constants:
| Constant | Value | Purpose |
|---|---|---|
FLASH_EXPIRY_BLOCKS | 100 | How long a signed tx stays valid after signing |
FLASH_PRUNE_BUFFER | 50 | Safety margin for DAG depth variance and reorgs |
FLASH_PRUNE_WINDOW | 150 | Effective window = 100 + 50 |
An entry becomes eligible for pruning once the current DAA score exceeds expiry_daa_score + FLASH_PRUNE_BUFFER:
pub fn prune_recent_flashes(&mut self, block_daa_score: u64) {
let cutoff = block_daa_score.saturating_sub(FLASH_PRUNE_BUFFER);
self.recent_flashes.retain(|e| e.expiry_daa_score > cutoff);
}Why 100 + 50?
The two constants are not arbitrary. Both are grounded in the protocol's timing assumptions:
- 100 blocks is long enough that a valid transaction is virtually guaranteed to be mined within its window, even under adversarial network conditions. At 1 block per second, 100 seconds is the worst-case delay a user should tolerate.
- 50-block buffer exists because ghostDAG's DAA score advances faster than the selected chain depth. A block at chain depth 100 may see a DAA score 30-40 higher than the reference point at which the transaction was signed. The buffer guarantees that pruning never removes an entry that could still be relevant to a block in flight.
Without the buffer, an attacker could wait for a flash entry to expire, then re-broadcast the original transaction in a block whose DAA score was just past expiry — but whose parent's root still contained the entry. The buffer closes this attack.
Committed Inside the Account Leaf
This is the part that makes Sahyadri's approach distinct from any nonce-based scheme. The recent_flashes vector is not held in a side table — it is part of the account state that the SMT commits to.
The canonical encoding is:
state_hash = SHA3(
"SAHYADRI_ACCOUNT_STATE_V1" ||
balance (u64 LE) ||
flash_count (u32 LE) ||
for each flash sorted by flash_id:
flash_id (32 bytes) || expiry_daa_score (u64 LE)
)Two consequences follow:
- Balance and replay protection are cryptographically bound. A block cannot update one without the other being part of the same commitment. There is no window where a wallet sees a new balance but a node still thinks the old replay window is current.
- The
block_hashfield is excluded from the hash. It is local to each node's reorg bookkeeping — the network does not commit to which block added a flash entry. Two nodes that saw the same flash in different blocks still agree on state.
Compare this to a nonce-based scheme, where the nonce lives in a separate store from the balance. A wallet reading both must trust they are consistent — which requires an RPC that reads both atomically. In Sahyadri, one state proof simultaneously attests to balance and replay window. One proof, two guarantees.
Why Sorting Matters
Before hashing, the flash entries are sorted by flash_id. This is not cosmetic — it is a consensus rule.
During a reorg, the vector's in-memory order can change. Entries may be re-inserted in a different sequence, pruned and rebuilt, or distributed across multiple blocks in the mergeset. If the hash were order-sensitive, a reorg could silently alter the committed root and fork the network.
Sorting eliminates that failure mode. Any permutation of the same set of entries produces the same hash:
let mut flashes: Vec<&FlashEntry> = self.recent_flashes.iter().collect(); flashes.sort_by(|a, b| a.flash_id.as_bytes().cmp(&b.flash_id.as_bytes()));
This is the same property that makes the SMT itself order-independent. Both must hold for the network to converge.
Reorg Handling
Each FlashEntry carries the block_hash of the block that added it. When a block is removed from the virtual chain, the reorg handler removes only the entries whose block_hash matches that block:
pub fn reorg_block_flashes(&mut self, block_hash: &Hash) -> bool {
let before = self.recent_flashes.len();
self.recent_flashes.retain(|e| &e.block_hash != block_hash);
self.recent_flashes.len() < before
}This is safe under ghostDAG because parallel flash transactions from other blocks are untouched. Their entries remain, so they stay replay-protected. Only the unwound block's contributions are removed.
After unwinding, the reorg handler applies the newly-canonical blocks in the standard order. Any flash tx that appeared in a removed block and then reappeared in an added block is treated as a fresh apply — its flash_id is re-inserted with the new block's hash. Because duplicates are skipped rather than rejected, a flash tx that is already present from a parallel block does not cause the added block to be rejected.
Attack Scenarios
recent_flashes already contains the flash_id — the validator rejects it before any state change.expiry_daa_score, the tx is rejected as expired regardless of whether the flash_id was pruned. Two independent checks must both pass.HashSet of flash_ids and skips duplicates — it does not reject the whole batch, because in a DAG the same logical flash can legitimately appear in more than one block of the same mergeset.expiry_daa_score — if the window has passed, the tx is rejected as expired before it can be re-applied.recent_flashes — rejected identically to any other replay.Batch Validation Algorithm
Before any state is touched, a block's flash transactions are validated as a batch. This closes several attack vectors at once:
pub fn validate_flash_batch(
&self,
txs: &[FlashTransaction],
block_daa_score: u64,
) -> TxResult<()> {
use std::collections::{HashMap, HashSet};
if txs.is_empty() { return Ok(()); }
// 1. Aggregate sender debits — check total balance sufficiency
let mut sender_debits: HashMap<ScriptPublicKey, u64> = HashMap::new();
for tx in txs {
let spk = pubkey_to_p2pkh_spk(&tx.pubkey);
let entry = sender_debits.entry(spk).or_insert(0);
*entry = entry
.checked_add(tx.amount)
.and_then(|v| v.checked_add(tx.fee))
.ok_or(TxRuleError::InputAmountOverflow)?;
}
// 2. Cumulative balance check — all-or-nothing per sender
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 this batch — skip, don't reject.
// Under mergeset acceptance, the same logical flash can appear in
// parallel blocks. Rejecting the whole batch on a duplicate would
// silently drop unrelated flash transactions from the same block.
let mut seen = HashSet::new();
// 4. Expiry check per tx
// 5. Replay check per sender
for tx in txs {
let id = tx.flash_id();
if !seen.insert(id) {
continue; // duplicate within this batch — skip
}
if block_daa_score > tx.expiry_daa_score {
continue; // expired — skip
}
let spk = pubkey_to_p2pkh_spk(&tx.pubkey);
let account = self.account_store.get(&spk)?;
if account.is_flash_replay(&id) {
continue; // already applied elsewhere — skip
}
}
Ok(())
}The order matters. Balance checks come first because they are cheap. Signature verification comes last because Dilithium3 verification is expensive and only runs if all cheaper checks pass. Notably, duplicates, expired entries, and replays are all skipped rather than rejected — a single bad transaction should not cause an entire block's batch to be dropped.
Comparison with Other Schemes
| Scheme | Mechanism | Order required | Separate registry | State commitment |
|---|---|---|---|---|
| Bitcoin UTXO | Output references | Yes | Implicit in UTXO set | UTXO set hash |
| Ethereum | Monotonic nonce | Yes (strict) | Yes, per-account | State trie (includes nonce) |
| Cosmos | Account sequence | Yes | Yes | IAVL tree (includes seq) |
| Solana | Recent blockhash | Yes | Yes, blockhash cache | Not in state commitment |
| Stellar | Time-bounded sequence window | Yes (per account) | Yes | Ledger entry |
| Monero | Key image set | No | Yes | Key image set hash |
| Sahyadri | Bounded flash_id window | Not required | No | Inside account leaf |
Time-bounded replay windows are not unique to Sahyadri — Stellar, Monero, and several newer DAG chains use variations of the same idea. What is different here is the combination: an order-independent window that lives inside the same leaf as balance, is committed by the same SMT root, and is verified by the same proof. There is no side registry and no cross-store consistency to maintain.
Memory and Storage Footprint
The replay window is bounded by the DAA-score window, not by transaction count. Its cost depends on how many flash transactions a single account sends within ~150 seconds of DAA time.
| Metric | Per Account | Per 1M Accounts |
|---|---|---|
| Typical entries | 0-5 | 0-5M |
| Bytes per entry | 72 (32 id + 8 expiry + 32 block_hash) | 72 MB (worst case) |
| Typical footprint | ~360 bytes | ~360 MB |
In practice, most accounts have zero recent flashes at any given moment — the window clears them once no block has touched the account for ~150 DAA score. Only accounts that have sent a flash transaction within that window carry any entries at all. A hard cap on entries per account is a planned follow-up; today, an adversary can inflate their own leaf within the window, at the cost of their own storage and their own transaction fees.
Interaction with SMT
The replay window is not just stored next to the SMT — it is inside the leaf. This creates a specific pattern worth noting:
- Two flash txs between the same two accounts, at different times, produce different
state_hashvalues for both accounts. - Two flash txs that touch no accounts in common produce independent SMT updates.
- A coinbase reward to an account that also sent a flash in the same block touches the account once — the state transition function composites both effects into a single leaf update.
- An account that no block has touched for longer than
FLASH_PRUNE_WINDOWkeeps its expired entries until some future block touches it. When that block runs, all expired entries are pruned in a single pass, and the leaf collapses to what it would have been had no flash ever been sent. State compaction is lazy, not eager.
There is no "replay registry" to sync, no separate Merkle tree, and no periodic compaction job. The account leaf is the source of truth for balance, replay protection, and — through the SMT — the entire network's account state.
Known Limitations
- No hard cap on entries per account. An adversary can flood their own account with flash transactions inside a single window, inflating their leaf until the window clears. This costs them fees and storage but does not affect other accounts. A per-account cap is planned.
- Duplicate handling under mergeset. Batch validation currently needs to skip duplicates (rather than reject the batch) to correctly handle the DAG case where a flash transaction is mined in parallel blocks. This is a correctness fix in progress.
- Pruning must use the block's own DAA score. If pruning is driven by the virtual tip's DAA score instead of the block being processed, two nodes with different virtual tips could prune different entries and produce different roots. The rule is that pruning runs on touched accounts only, using the block's own DAA score.
- Legacy account transactions still accepted. The chain's account-model-native path is flash-only, but the validator does not yet reject legacy account txs. Until it does, they are treated as no-ops for the SMT commitment.
Summary
Replay protection in Sahyadri is not a side system. It is part of what account state means. Balance and replay markers are committed together by the same SMT leaf, verified by the same proof, and updated by the same pure transition function. Every property of the SMT — canonical roots, content addressing, atomic commitment — extends automatically to replay protection.
The result is a scheme that is order-independent (works naturally in a DAG), registry-free (no side table), and cryptographically bound to balance. It borrows the time-window idea from chains like Stellar and Monero, and adapts it to a DAG context where ordering assumptions cannot be made.
The tradeoff is small: an extra 16 bytes of salt in every flash transaction, and a bounded vector per account that self-prunes lazily when the account is next touched.