fix(drive,drive-abci): retire SHIELDED_MOST_RECENT_ANCHOR_KEY; derive most-recent from [8] and never empty it#3605
Conversation
… most-recent from [8] and never empty it
The shielded pool kept the live anchor in two places:
* `[..., "s", [6]]` (`SHIELDED_ANCHORS_IN_POOL_KEY`) — the lookup
table validate_anchor_exists reads, written and pruned together
with `[8]`.
* `[..., "s", [7]]` (`SHIELDED_MOST_RECENT_ANCHOR_KEY`) — a
redundant single-slot mirror of the latest anchor, written by
record_shielded_pool_anchor_if_changed and never touched by
prune.
After ≥ retention_blocks (1000) of shielded inactivity, prune
removed the only entry from `[6]`/`[8]`, but `[7]` retained its
value because record was guarded by "anchor changed since last
record" and the anchor hadn't changed for 1000 blocks. The
state ended up with:
* getMostRecentShieldedAnchor → live anchor (from `[7]`)
* getShieldedAnchors → empty (from `[6]`)
* validate_anchor_exists → false → every spend rejected with
InvalidAnchorError until a new shield op refreshed `[6]`.
Two changes that together remove the failure mode:
1. Drop `[7]` entirely. The most-recent anchor is now derived
from a `limit 1` reverse query against `[8]` — single source
of truth, can't desync.
- paths.rs: remove the constant; document the retirement.
- initialization/v3: drop the `[7]` init insert.
- record_anchor_if_changed/v0: read latest from `[8]`,
compare, insert `[6]`+`[8]` when changed. Drop the
stale `!= [0; 32]` guard (which was a defense against
`[7]`'s uninitialised state, not a real "empty pool"
gate — the Sinsemilla empty root is non-zero).
- query/shielded/most_recent_anchor/v0 + verify
equivalent: replay the same `limit 1` reverse path query
so proofs match.
2. prune_shielded_pool_anchors_v0 now preserves the highest
`[8]` entry whenever pruning would otherwise empty the
index (i.e. every entry is below cutoff). The retention
invariant relaxes to "keep at most one stale anchor when
the pool sits idle past the retention window" — bounded
and acceptable, vs. the old behavior of "empty the lookup
table and freeze every spend." Probes for any entry ≥
cutoff with `limit 1` to skip the special case when the
live anchor is already recent.
A new `Drive::read_latest_recorded_shielded_anchor_v0` helper
encapsulates the reverse query so record/query/verify all share
the canonical `PathQuery` (in `shielded_latest_recorded_anchor_path_query`).
The strategy test that previously asserted on `[7]` now reads
the helper instead.
Five new unit tests cover the regression:
- `record_on_empty_pool_records_the_sinsemilla_empty_root`
- `record_idempotent_when_anchor_unchanged`
- `read_latest_returns_highest_height_entry`
- `prune_keeps_highest_when_all_below_cutoff` (the desync case)
- `prune_keeps_single_old_entry`
3043 drive lib tests + 22 verify-shielded + 54 drive-abci
shielded query + 15 shielded-common tests pass; clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Caution Review failedFailed to post review comments 📝 WalkthroughWalkthroughRefactors shielded anchor storage from a dedicated "most recent" key-value slot to a height-indexed log model. Introduces ChangesShielded Anchor Storage Refactor
Sequence DiagramsequenceDiagram
participant Client
participant QueryHandler as Query Handler
participant Storage as Storage<br/>(anchors_path,<br/>anchors_by_height_path)
participant PathQuery as PathQuery<br/>(limit-1 reverse)
participant Verifier as Verifier
Note over Client,Verifier: New Flow: Height-Indexed Anchor Log
Client->>QueryHandler: query_most_recent_shielded_anchor_v0()
QueryHandler->>QueryHandler: Construct shielded_latest_recorded_anchor_path_query()
QueryHandler->>PathQuery: Create limit-1 reverse query over anchors_by_height
QueryHandler->>Storage: grove_get_proved_path_query(path_query)
Storage-->>QueryHandler: proved_key_values (highest height anchor)
QueryHandler->>QueryHandler: Extract 32-byte anchor from result
QueryHandler-->>Client: Return Proof(anchor) or Proof(zero)
Note over Client,Verifier: Anchor Recording
Client->>Storage: record_anchor_if_changed_v0(current_anchor)
Storage->>Storage: Read latest_recorded via path_query
alt Anchor changed
Storage->>Storage: Write current_anchor → anchors_path
Storage->>Storage: Write block_height → anchors_by_height_path
end
Storage-->>Client: Anchor recorded at height
Note over Client,Verifier: Verification
Client->>Verifier: verify_most_recent_shielded_anchor_v0(proof)
Verifier->>PathQuery: Use shielded_latest_recorded_anchor_path_query
Verifier->>Verifier: Match proved_key_values, decode 32-byte anchor
Verifier->>Verifier: Select highest height if multiple matches
Verifier-->>Client: Verified anchor or error
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~60 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 6dfa0fb) |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3605 +/- ##
==========================================
Coverage 88.29% 88.29%
==========================================
Files 2479 2479
Lines 301541 301660 +119
==========================================
+ Hits 266231 266350 +119
Misses 35310 35310
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Solid fix to the SHIELDED_MOST_RECENT_ANCHOR_KEY desync. The shared shielded_latest_recorded_anchor_path_query helper makes record/query/verify byte-aligned, and prune now correctly preserves the highest entry. One blocker: add_estimation_costs_for_shielded_pool_operations in estimated_costs.rs still describes the pre-PR pool shape (9 elements, 2 items) and was not updated for the retired [7] slot. Several quality/doc nits worth folding in before merge.
Reviewed commit: 6dfa0fb
🔴 1 blocking | 🟡 3 suggestion(s) | 💬 3 nitpick(s)
7 additional findings
🔴 blocking: Shielded fee estimation still models the retired key-7 slot
packages/rs-drive/src/drive/shielded/estimated_costs.rs (lines 78-106)
This PR removed the SHIELDED_MOST_RECENT_ANCHOR_KEY = 7 Item([u8;32]) slot from the pool, but add_estimation_costs_for_shielded_pool_operations still registers the pool with the pre-PR layout: comment says 9 elements total (7 subtrees + 2 items), EstimatedLevel(4, false), items_size: Some((1, 32, None, 2)), and the inline comment lists most recent anchor (Item) as the second item. After this PR the pool actually contains 7 subtrees + 1 item (the SHIELDED_TOTAL_BALANCE_KEY SumItem) — ceil(log2(8)) = 3, and only one item slot.
This metadata feeds ShieldedPoolOperationType::into_low_level_drive_operations() whenever shielded ops are costed in estimation mode (apply=false), so every shielded note/nullifier/balance fee estimate continues to reflect the old shape. The state-shape change is the whole point of the PR; the cost estimator needs to be updated in lockstep, otherwise stateless fee estimation is structurally lying about pool size and item count.
💡 Suggested change
// Shielded credit pool: [AddressBalances, "s"]
// SumTree containing: notes (CommitmentTree), permanent nullifiers (ProvableCountTree),
// total balance (SumItem), anchors (NormalTree), anchors-by-height (NormalTree),
// recent nullifiers (CountSumTree), compacted nullifiers (NormalTree),
// expiration time (NormalTree)
// 8 elements total (7 subtrees + 1 item) → balanced Merk depth = ceil(log2(8)) = 3
estimated_costs_only_with_layer_info.insert(
KeyInfoPath::from_known_path(shielded_credit_pool_path()),
EstimatedLayerInformation {
tree_type: TreeType::SumTree,
estimated_layer_count: EstimatedLevel(3, false),
estimated_layer_sizes: Mix {
subtrees_size: Some((
1,
SomeSumTrees {
sum_trees_weight: 0,
big_sum_trees_weight: 0,
count_trees_weight: 1, // permanent nullifiers (ProvableCountTree)
count_sum_trees_weight: 1, // recent nullifiers (CountSumTree)
non_sum_trees_weight: 5, // notes (CommitmentTree), anchors, anchors-by-height, compacted nullifiers, expiration time
},
None,
7, // 7 subtrees: notes, permanent nullifiers, anchors, anchors-by-height, recent nullifiers, compacted nullifiers, expiration time
)),
items_size: Some((1, 32, None, 1)), // 1 item: total balance (SumItem)
references_size: None,
},
},
);
🟡 suggestion: Use `.pop()` on the already-sorted result instead of recomputing max_key
packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs (lines 95-110)
entries_below comes from grove_get_raw_path_query over a Query::new() (default left_to_right = true) with RangeTo, so GroveDB returns the entries in ascending key order (big-endian u64 → numeric ordering). The current code clones every key into a new Vec<u8> just to find the maximum, then walks the vector again to filter that key out — both O(N) extra work plus an allocation per entry on a path that runs every block.
Since the highest-block-height entry is always the last element, entries_below.pop() (and dropping the popped value) accomplishes the same thing and makes the intent obvious.
💡 Suggested change
let to_delete: Vec<(Vec<u8>, Element)> = if any_above_cutoff {
entries_below
} else {
// Range-query results come back in ascending key order;
// the live anchor is the last element. Drop it; delete
// the rest.
let mut entries_below = entries_below;
entries_below.pop();
entries_below
};
🟡 suggestion: Asymmetric empty-index handling between proven and non-proven branches
packages/rs-drive-abci/src/query/shielded/most_recent_anchor/v0/mod.rs (lines 61-78)
When the anchors-by-height index is empty:
- The non-proven branch returns
Anchor(vec![0u8; 32])and relies on a downstream response decoder to translate the all-zeros sentinel intoNone. - The proven branch returns
Proof(...); the SDK then runsverify_most_recent_shielded_anchor_v0, which (in this PR) returnsOk(None)for the genuinely-empty case — no zero-sentinel translation involved.
The two paths only stay aligned if the SDK's response decoder convention is preserved exactly. Now that the storage shape uses real absence (rather than a pre-initialised Item([0;32]) slot), the cleanest fix is to extend the gRPC response with an explicit "absent" variant alongside Anchor/Proof, or to make Anchor an Option. At minimum, add a comment cross-referencing the SDK decoder so the coupling isn't invisible to future maintainers.
🟡 suggestion: `read_latest_recorded_shielded_anchor_v0` is `pub` purely for test convenience; doc cites a caller that doesn't use it
packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs (lines 126-134)
This helper is exposed as pub. The doc comment justifies that visibility with two callers: "drive-abci's strategy tests + the getMostRecentShieldedAnchor non-proven query path." The first is real; the second is not — query_most_recent_shielded_anchor_v0 calls grove_get_raw_path_query directly on shielded_latest_recorded_anchor_path_query() and never goes through this helper (it returns a vec![0u8; 32] sentinel; this helper returns Option<[u8;32]>).
So the only real reason this is pub rather than pub(in crate::drive) (matching record_shielded_pool_anchor_if_changed_v0) is to let the drive-abci strategy test reach across crates. Exposing internal storage helpers on the public Drive API surface for test convenience forces forward-compat guarantees on something that has no business being part of the SDK contract. Options: (a) #[doc(hidden)] + test-only documentation, (b) gate behind a testing-helpers feature consumed by drive-abci's [dev-dependencies], or (c) keep pub(in crate::drive) and have the strategy test seed/read [8] directly. At minimum, fix the doc comment so it doesn't claim a caller that doesn't exist.
💬 nitpick: Dispatcher docstring still describes the pre-PR contract
packages/rs-drive/src/drive/shielded/record_anchor_if_changed/mod.rs (lines 10-17)
The public dispatcher's docstring still says: "compares it to the most recent stored anchor, and if different (and non-zero) writes entries to the anchors tree, anchors-by-height tree, and updates the most recent anchor item." After this PR there is no [7] slot to update, and the != [0; 32] guard is gone — the new v0 records the Sinsemilla empty root from the very first block-end. The v0 file's docstring was updated; this caller-facing one was missed, so rust-doc on Drive::record_shielded_pool_anchor_if_changed shows the obsolete contract.
💡 Suggested change
/// Records the current shielded pool anchor if the commitment tree changed
/// this block.
///
/// Reads the current Sinsemilla anchor from the CommitmentTree and compares
/// it to the latest entry in the anchors-by-height index (`[..., "s", [8]]`,
/// derived via a `limit 1` reverse query). If different, writes the new
/// anchor to both the anchors tree (`[..., [6]]`) and the anchors-by-height
/// index (`[..., [8]]`). There is no separate "most recent anchor" slot —
/// the by-height index is the canonical source.
///
/// # Parameters
/// - `block_height`: The current block height
/// - `transaction`: The GroveDB transaction
/// - `platform_version`: The platform version for dispatch
💬 nitpick: Comment points readers at `Drive::query_most_recent_shielded_anchor`, but that function lives on `Platform`
packages/rs-drive/src/drive/shielded/paths.rs (lines 23-29)
The retire-key-7 comment block points readers at Drive::query_most_recent_shielded_anchor. There is no such method on Drive. query_most_recent_shielded_anchor_v0 is implemented on Platform<C> in packages/rs-drive-abci/src/query/shielded/most_recent_anchor/v0/mod.rs. The Drive-side equivalent is read_latest_recorded_shielded_anchor_v0 (or the canonical shielded_latest_recorded_anchor_path_query defined just below). Minor, but the wrong receiver makes the pointer hard to follow.
💡 Suggested change
// Key 7 was previously `SHIELDED_MOST_RECENT_ANCHOR_KEY`, a redundant
// `Item([u8;32])` slot mirroring the latest entry in
// `SHIELDED_ANCHORS_BY_HEIGHT_KEY`. It was removed because the duplicated
// state could (and did) drift out of sync with the anchors tree under prune,
// leaving the validator's lookup table empty while the pool was still live.
// The most-recent anchor is now derived from `[8]` via a `limit 1` reverse
// query — see `shielded_latest_recorded_anchor_path_query` below and its
// three call sites: `Drive::read_latest_recorded_shielded_anchor_v0`,
// `Platform::query_most_recent_shielded_anchor_v0`, and
// `Drive::verify_most_recent_shielded_anchor_v0`.
💬 nitpick: Recording the Sinsemilla empty root is now load-bearing on "can't be spent" — assert it explicitly
packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs (lines 64-79)
The PR intentionally drops the current_anchor_bytes != [0u8; 32] guard, with the rationale that "the Sinsemilla empty root is a non-zero hash and recording it on first block-end after init is harmless (no notes against an empty pool to spend)." The new record_on_empty_pool_records_the_sinsemilla_empty_root test confirms the empty root is stored in [6] (i.e. validate_anchor_exists(empty_root) → true).
The safety claim — that no spend can actually succeed against the empty-root anchor — is enforced elsewhere (the spend's zk-proof would need to demonstrate ownership of a note in an empty tree). It's a consensus-critical invariant the PR now relies on, and it isn't exercised by any test in this diff. A defensive test that constructs a spend bundle anchored at the empty root and asserts it is rejected (by zk-proof validation, not by InvalidAnchorError) would harden this against future regressions in upstream zk-proof crates and document the load-bearing assumption.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-drive/src/drive/shielded/estimated_costs.rs`:
- [BLOCKING] lines 78-106: Shielded fee estimation still models the retired key-7 slot
This PR removed the `SHIELDED_MOST_RECENT_ANCHOR_KEY = 7` `Item([u8;32])` slot from the pool, but `add_estimation_costs_for_shielded_pool_operations` still registers the pool with the pre-PR layout: comment says `9 elements total (7 subtrees + 2 items)`, `EstimatedLevel(4, false)`, `items_size: Some((1, 32, None, 2))`, and the inline comment lists `most recent anchor (Item)` as the second item. After this PR the pool actually contains 7 subtrees + 1 item (the `SHIELDED_TOTAL_BALANCE_KEY` SumItem) — `ceil(log2(8)) = 3`, and only one item slot.
This metadata feeds `ShieldedPoolOperationType::into_low_level_drive_operations()` whenever shielded ops are costed in estimation mode (`apply=false`), so every shielded note/nullifier/balance fee estimate continues to reflect the old shape. The state-shape change is the whole point of the PR; the cost estimator needs to be updated in lockstep, otherwise stateless fee estimation is structurally lying about pool size and item count.
In `packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs`:
- [SUGGESTION] lines 95-110: Use `.pop()` on the already-sorted result instead of recomputing max_key
`entries_below` comes from `grove_get_raw_path_query` over a `Query::new()` (default `left_to_right = true`) with `RangeTo`, so GroveDB returns the entries in ascending key order (big-endian `u64` → numeric ordering). The current code clones every key into a new `Vec<u8>` just to find the maximum, then walks the vector again to filter that key out — both O(N) extra work plus an allocation per entry on a path that runs every block.
Since the highest-block-height entry is always the last element, `entries_below.pop()` (and dropping the popped value) accomplishes the same thing and makes the intent obvious.
In `packages/rs-drive-abci/src/query/shielded/most_recent_anchor/v0/mod.rs`:
- [SUGGESTION] lines 61-78: Asymmetric empty-index handling between proven and non-proven branches
When the anchors-by-height index is empty:
- The non-proven branch returns `Anchor(vec![0u8; 32])` and relies on a downstream response decoder to translate the all-zeros sentinel into `None`.
- The proven branch returns `Proof(...)`; the SDK then runs `verify_most_recent_shielded_anchor_v0`, which (in this PR) returns `Ok(None)` for the genuinely-empty case — no zero-sentinel translation involved.
The two paths only stay aligned if the SDK's response decoder convention is preserved exactly. Now that the storage shape uses real absence (rather than a pre-initialised `Item([0;32])` slot), the cleanest fix is to extend the gRPC response with an explicit "absent" variant alongside `Anchor`/`Proof`, or to make `Anchor` an `Option`. At minimum, add a comment cross-referencing the SDK decoder so the coupling isn't invisible to future maintainers.
In `packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs`:
- [SUGGESTION] lines 126-134: `read_latest_recorded_shielded_anchor_v0` is `pub` purely for test convenience; doc cites a caller that doesn't use it
This helper is exposed as `pub`. The doc comment justifies that visibility with two callers: "drive-abci's strategy tests + the `getMostRecentShieldedAnchor` non-proven query path." The first is real; the second is not — `query_most_recent_shielded_anchor_v0` calls `grove_get_raw_path_query` directly on `shielded_latest_recorded_anchor_path_query()` and never goes through this helper (it returns a `vec![0u8; 32]` sentinel; this helper returns `Option<[u8;32]>`).
So the only real reason this is `pub` rather than `pub(in crate::drive)` (matching `record_shielded_pool_anchor_if_changed_v0`) is to let the drive-abci strategy test reach across crates. Exposing internal storage helpers on the public `Drive` API surface for test convenience forces forward-compat guarantees on something that has no business being part of the SDK contract. Options: (a) `#[doc(hidden)]` + test-only documentation, (b) gate behind a `testing-helpers` feature consumed by drive-abci's `[dev-dependencies]`, or (c) keep `pub(in crate::drive)` and have the strategy test seed/read `[8]` directly. At minimum, fix the doc comment so it doesn't claim a caller that doesn't exist.
Issue being fixed or feature implemented
The shielded credit pool's anchor lookup table (
[..., \"s\", [6]])could be silently emptied by prune while the pool was still live,
freezing every shielded spend with
InvalidAnchorErroruntil anew shield op refreshed state. Verified via grpcurl on a stuck
regtest:
Reproducer: shield once, mine ≥
shielded_anchor_retention_blocks(1000) blocks of inactivity, attempt any spend.
What was done?
Two structural changes that together remove the failure mode.
1. Retire
SHIELDED_MOST_RECENT_ANCHOR_KEY = 7The pool kept the live anchor in two places:
[..., \"s\", [6]]—SHIELDED_ANCHORS_IN_POOL_KEY, thelookup table that
validate_anchor_existsreads. Written andpruned together with
[8].[..., \"s\", [7]]—SHIELDED_MOST_RECENT_ANCHOR_KEY, aredundant single-slot mirror of the latest anchor. Written by
record_shielded_pool_anchor_if_changed, never touched byprune.
record_*was guarded by anchor changed since last record. Oncethe pool sat idle for ≥ 1000 blocks, the anchor stopped changing
→
record_*no-op'd every block →[7]retained its value butprune emptied
[6]+[8]by height range, with no awareness ofwhich anchor was live. End state:
[7]says X,[6]is empty,every spend bundle that anchors at X (i.e. every valid one)
hits
InvalidAnchorError.The slot is genuinely redundant: the most-recent anchor is just
the value of the highest-block-height entry in
[8]. Removingit eliminates the duplicate state that made the desync possible.
paths.rs: remove the constant; leave a comment block in itsplace documenting why key 7 is retired.
initialization/v3/mod.rs: drop the[7]init insert.record_anchor_if_changed/v0: read latest from[8]via alimit 1reverse query; insert into[6]+[8]only whenchanged. Drop the stale
!= [0; 32]guard — it was adefense against
[7]'s uninitialised state, not a real"empty pool" gate. The Sinsemilla empty root is a non-zero
hash and recording it on first block-end after init is
harmless (no notes against an empty pool to spend).
query/shielded/most_recent_anchor/v0+ the matchingverify/shielded/verify_most_recent_shielded_anchor/v0replay the same
limit 1reversePathQueryso proofs andverifier line up byte-for-byte.
A new
Drive::read_latest_recorded_shielded_anchor_v0helperencapsulates the reverse query so
record/ non-provenquery/ strategy tests all share the canonical
PathQuery(definedonce in
shielded_latest_recorded_anchor_path_query).2. Prune never empties
[8]prune_shielded_pool_anchors_v0now preserves thehighest-block-height entry whenever every entry in
[8]isbelow the cutoff (i.e. the live anchor itself is older than
retention_blocks). The retention invariant relaxes to keepat most one stale anchor while the pool sits idle past the
retention window — bounded, harmless, and the difference
between "validator can find the live anchor" and "every spend
fails." Cheap: probes for any entry ≥ cutoff with
limit: 1sothe special case is only entered when the live anchor is
already old.
How Has This Been Tested?
drivelib tests pass (prune + record + verifymodules):
record_on_empty_pool_records_the_sinsemilla_empty_rootrecord_after_note_insert_stores_anchorrecord_idempotent_when_anchor_unchangedread_latest_returns_highest_height_entryprune_keeps_highest_when_all_below_cutoff← the desyncregression test
prune_keeps_single_old_entryprune_removes_all_below_cutoff_when_a_recent_anchor_existsprune_preserves_all_at_or_above_cutoffshould_prove_and_verify_most_recent_shielded_anchor_presentshould_prove_and_verify_most_recent_shielded_anchor_absenthighest_block_height_winsdrive-abcishielded query tests pass.driveverify-shielded tests pass.drive-abcishielded execution tests pass.cargo clippy -p drive -p drive-abci --all-targetsclean.cargo fmt --allclean.Breaking Changes
State-shape change to the shielded credit pool:
written by
record_*, and no longer read anywhere. Existingstate that has a
[7]entry from before this PR is left asa dead fossil (no migration needed).
getMostRecentShieldedAnchorproofs change shape ([8]reverse vs.
[7]single-key). SDK clients that verifyproofs see the new shape via the matching change to
Drive::verify_most_recent_shielded_anchor_v0— noconsumer-visible API change at the gRPC layer (response
message is unchanged).
bodies are amended in place rather than gated behind a new
record_shielded_pool_anchor/prune_shielded_pool_anchorsfeature version.
Checklist:
Related
Surfaced while testing PR #3603 (shielded send wiring) on a regtest that had been idle for ≥ retention_blocks.
That PR is blocked on this fix landing — see its updated description for the diagnostic trail.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Refactor