# BLK-001: Store Block (`put`)

## Summary

Store a complete L2 block into the blockstore. The block body is zstd-compressed into `CF_BLOCKS`, the header is bincode-serialized into `CF_HEADERS`, a `BlockRecord` is derived and cached in-memory, and an optional canonical height mapping is written to `CF_CANONICAL`. The operation is idempotent.

## Specification

```rust
impl BlockStore {
    /// Store an L2 block. Returns `Ok(true)` if newly stored, `Ok(false)` if
    /// the block hash already existed (idempotent no-op).
    pub fn put(&self, block: &L2Block, canonical: bool) -> Result<bool> {
        let hash = block.header.hash();

        // Idempotency check
        if self.db.get_cf(&self.cf_blocks, hash.as_ref())?.is_some() {
            return Ok(false);
        }

        // Serialize & compress block body
        let block_bytes = bincode::serialize(block)?;
        let compressed = zstd::encode_all(&block_bytes[..], self.compression_level)?;
        self.db.put_cf(&self.cf_blocks, hash.as_ref(), &compressed)?;

        // Serialize header (uncompressed)
        let header_bytes = bincode::serialize(&block.header)?;
        self.db.put_cf(&self.cf_headers, hash.as_ref(), &header_bytes)?;

        // Derive BlockRecord and cache it (never persisted)
        let record = BlockRecord::from_header(&block.header);
        self.record_cache.insert(hash, record);

        // Canonical height mapping
        if canonical {
            let height_key = block.header.height.to_be_bytes();
            self.db.put_cf(&self.cf_canonical, &height_key, hash.as_ref())?;
        }

        Ok(true)
    }
}
```

## Acceptance Criteria

1. A newly stored block returns `Ok(true)`.
2. Storing a block whose hash already exists in `CF_BLOCKS` returns `Ok(false)` without overwriting any data.
3. `CF_BLOCKS` contains the zstd-compressed, bincode-serialized full block keyed by hash.
4. `CF_HEADERS` contains the uncompressed, bincode-serialized header keyed by hash.
5. The in-memory record cache contains a `BlockRecord` derived from the header after a successful put.
6. When `canonical=true`, `CF_CANONICAL` maps `height (big-endian u64)` to the block hash.
7. When `canonical=false`, `CF_CANONICAL` is not modified.

## Implementation Notes

- The idempotency check reads `CF_BLOCKS` by key; this is a point lookup and should be fast.
- `compression_level` should be configurable (default 3 is a good balance).
- Consider using a RocksDB `WriteBatch` to atomically write `CF_BLOCKS`, `CF_HEADERS`, and `CF_CANONICAL` in a single operation.
- The `BlockRecord` is derived purely from the header and is never written to disk.

## Test Plan

- **Unit: round-trip** -- Store a block with `put`, retrieve with `get_block`, verify equality.
- **Unit: idempotent duplicate** -- Call `put` twice with the same block; first returns `Ok(true)`, second returns `Ok(false)`.
- **Unit: canonical mapping** -- Store with `canonical=true`, verify `CF_CANONICAL` entry; store with `canonical=false`, verify no entry.
- **Unit: record cache populated** -- After `put`, call `get_record` and verify it returns the expected `BlockRecord` from cache.
- **Unit: header stored uncompressed** -- Read raw bytes from `CF_HEADERS`, deserialize directly with bincode (no decompression), verify match.

## Expected Test Files

- `tests/blk_001_tests.rs`
