MENU
IDENTITY
Identity ▼
ACCOUNT
Account ▼
STATE & PROOFS
State & Proofs ▼
CRYPTOGRAPHY
Cryptography ▼
RESOURCES

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:

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.

Distinct in Sahyadri: there is no separate "spent transaction" registry. Replay markers are committed inside the same SMT leaf as balance. Two accounts sending the same amount to each other at the same moment cannot conflict, and the state commitment cryptographically attests to the entire replay window.

Why Not Nonces?

Nonces work well in a linear chain. They break down in a DAG for three reasons:

ProblemNonce-based (Ethereum)Sahyadri replay window
Parallel transactionsMust be serialized by nonce; can't send N in parallelAny N can be in flight simultaneously
DAG orderingNonce assumes a strict per-account orderOrder does not exist; only the set of applied flash_ids
Account recoveryLose a nonce in a reorg, state gets stuckReorg removes only the unwound block's entries; nothing gets stuck
Cross-device walletsTwo devices must sync nonces — race conditionsEach 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:

ConstantValuePurpose
FLASH_EXPIRY_BLOCKS100How long a signed tx stays valid after signing
FLASH_PRUNE_BUFFER50Safety margin for DAG depth variance and reorgs
FLASH_PRUNE_WINDOW150Effective 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);
}
Determinism rule: pruning runs only on accounts touched by a block, and it uses that block's own DAA score — not the virtual tip's. "Touched" is decided by the block's effects, which are deterministic across nodes. Every node that accepts the same block prunes exactly the same entries, and no others. Accounts that no block touches keep their entries until some later block touches them.

Why 100 + 50?

The two constants are not arbitrary. Both are grounded in the protocol's timing assumptions:

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:

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

Direct Replay
Attacker rebroadcasts the same signed flash tx. Sender's recent_flashes already contains the flash_id — the validator rejects it before any state change.
Mutation Replay
Attacker changes amount or recipient to produce a new flash_id. The Dilithium3 signature no longer verifies over the changed payload — rejected at signature check.
Delayed Replay
Attacker holds the tx for later. Once past expiry_daa_score, the tx is rejected as expired regardless of whether the flash_id was pruned. Two independent checks must both pass.
Cross-Account Replay
Attacker tries to use Alice's signed tx against Bob's account. The pubkey inside the flash tx determines the sender — Bob's account never sees the tx, because the sender identity is derived from the signed payload, not from the recipient field.
Batch Duplicate
Same flash_id appears twice in one block's transaction list, or a flash_id that was already applied by a parallel block appears again. The batch validator builds a 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.
Reorg Replay
Block B applied flash F, then B was reorged, then attacker rebroadcasts F. The reorg removed F's entry (its block_hash matched B). But F's signature carries expiry_daa_score — if the window has passed, the tx is rejected as expired before it can be re-applied.
Insider Broadcast
The recipient of a flash tx rebroadcasts it to receive a double credit. The sender's flash_id is already in 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

SchemeMechanismOrder requiredSeparate registryState commitment
Bitcoin UTXOOutput referencesYesImplicit in UTXO setUTXO set hash
EthereumMonotonic nonceYes (strict)Yes, per-accountState trie (includes nonce)
CosmosAccount sequenceYesYesIAVL tree (includes seq)
SolanaRecent blockhashYesYes, blockhash cacheNot in state commitment
StellarTime-bounded sequence windowYes (per account)YesLedger entry
MoneroKey image setNoYesKey image set hash
SahyadriBounded flash_id windowNot requiredNoInside 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.

MetricPer AccountPer 1M Accounts
Typical entries0-50-5M
Bytes per entry72 (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:

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

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.