Skip to content

Commit fc311bb

Browse files
authored
Merge pull request #337 from BitGo/CSHLD-783-configurable-checkpoints
CSHLD-783: add support for custom checkpoints
2 parents 9cec077 + 8c8c745 commit fc311bb

6 files changed

Lines changed: 179 additions & 26 deletions

File tree

packages/wasm-privacy-coin/README.md

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ memory and reads a `Response` proto from the `LAST_RESULT` buffer.
130130

131131
**Persistence.** `save()` / `fromState()` use serde JSON internally (the
132132
`PersistedShardTreeState` format). This is the on-disk/DB format, not the Java↔WASM
133-
wire format. The Java layer sees it as opaque `TreeState` bytes.
133+
wire format. The Java layer sees it as opaque `TreeState` bytes. `PersistedShardTreeState`
134+
carries `max_checkpoints`, so a restored tree keeps its original checkpoint retention
135+
capacity across `save()`/`fromState()` round-trips.
134136

135137
**One instance = one tree.** Each `ShieldedMerkleTree` owns a dedicated Chicory
136138
`Instance` with its own WASM linear memory. Two instances never share state.
@@ -150,10 +152,11 @@ Implements `AutoCloseable`. Always use in try-with-resources.
150152

151153
#### Factory methods
152154

153-
| Method | Description |
154-
| -------------------------------------------------------- | ------------------------------------------------------------------------------ |
155-
| `static fromFrontier(byte[] frontier, long blockHeight)` | Initialize from a CommitmentTree v0 frontier (raw bytes from `z_gettreestate`) |
156-
| `static fromState(TreeState state)` | Restore from a `TreeState` previously returned by `save()` |
155+
| Method | Description |
156+
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
157+
| `static fromFrontier(byte[] frontier, long blockHeight)` | Initialize from a CommitmentTree v0 frontier (raw bytes from `z_gettreestate`); convenience overload, `maxCheckpoints` defaults to 100 |
158+
| `static fromFrontier(byte[] frontier, long blockHeight, Integer maxCheckpoints)` | Initialize from a frontier with a custom checkpoint retention capacity; `null` = default (100) |
159+
| `static fromState(TreeState state)` | Restore from a `TreeState` previously returned by `save()` |
157160

158161
#### Instance methods
159162

@@ -216,11 +219,11 @@ TreeState restored = TreeState.of(blob); // load from DB
216219

217220
Immutable snapshot returned by `getInfo()`.
218221

219-
| Field | Type | Description |
220-
| ----------------- | ------ | ------------------------------------------------------------------------ |
221-
| `tipHeight` | `Long` | Most recently checkpointed block height; `null` if no block appended yet |
222-
| `leafCount` | `long` | Total Orchard commitments appended across all blocks |
223-
| `checkpointCount` | `int` | Number of checkpoints currently retained (max 100) |
222+
| Field | Type | Description |
223+
| ----------------- | ------ | ---------------------------------------------------------------------------------- |
224+
| `tipHeight` | `Long` | Most recently checkpointed block height; `null` if no block appended yet |
225+
| `leafCount` | `long` | Total Orchard commitments appended across all blocks |
226+
| `checkpointCount` | `int` | Number of checkpoints currently retained, capped by `maxCheckpoints` (default 100) |
224227

225228
---
226229

@@ -255,12 +258,21 @@ try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(frontier, blockHe
255258
}
256259
```
257260

261+
To use a non-default checkpoint retention capacity (e.g. a shallower reorg-depth
262+
tolerance), pass an explicit `maxCheckpoints`:
263+
264+
```java
265+
try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(frontier, blockHeight, 20)) {
266+
// tree retains at most 20 checkpoints before pruning the oldest
267+
}
268+
```
269+
258270
### 2. Initialize from an empty state
259271

260272
```java
261273
TreeState emptyState = new TreeState(
262274
"{\"shards\":[],\"cap\":{\"type\":\"Nil\"},\"checkpoints\":[],"
263-
+ "\"tip_height\":null,\"leaf_count\":0}");
275+
+ "\"tip_height\":null,\"leaf_count\":0,\"max_checkpoints\":100}");
264276

265277
try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(emptyState)) {
266278
tree.ping();
@@ -330,7 +342,7 @@ The wire format between Java and Rust is declared in `proto/privacy_coin.proto`.
330342

331343
| Message | Export | Fields |
332344
| -------------------------- | ------------------------ | -------------------------------------------------------------------------------------- |
333-
| `FromFrontierRequest` | `from_frontier` | `frontier: bytes`, `block_height: uint32` |
345+
| `FromFrontierRequest` | `from_frontier` | `frontier: bytes`, `block_height: uint32`, `max_checkpoints: optional uint32` |
334346
| `AppendCommitmentsRequest` | `append_commitments` | `block_height: uint32`, `commitments: repeated bytes`, `expected_root: optional bytes` |
335347
| `TruncateRequest` | `truncate_to_checkpoint` | `block_height: uint32` |
336348

packages/wasm-privacy-coin/proto/privacy_coin.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ option java_multiple_files = true;
99
message FromFrontierRequest {
1010
bytes frontier = 1; // raw CommitmentTree v0 bytes
1111
uint32 block_height = 2;
12+
optional uint32 max_checkpoints = 3; // shardtree checkpoint capacity; absent = default (100)
1213
}
1314

1415
message AppendCommitmentsRequest {

packages/wasm-privacy-coin/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ pub unsafe extern "C" fn from_frontier(ptr: *const u8, len: u32) -> i32 {
122122
let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) };
123123
match FromFrontierRequest::decode(bytes) {
124124
Err(e) => write_error("DECODE_ERROR", &e.to_string()),
125-
Ok(req) => match zcash::tree::OwnedTree::from_frontier(&req.frontier, req.block_height) {
125+
Ok(req) => match zcash::tree::OwnedTree::from_frontier(
126+
&req.frontier,
127+
req.block_height,
128+
req.max_checkpoints.map(|v| v as usize),
129+
) {
126130
Ok(tree) => {
127131
TREE.with(|t| *t.borrow_mut() = Some(tree));
128132
write_ok();

packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,36 @@ private static ShieldedMerkleTree create(Consumer<WasmBridge> init) {
5959
* @throws WasmException if the frontier is invalid
6060
*/
6161
public static ShieldedMerkleTree fromFrontier(byte[] frontier, long blockHeight) {
62+
return fromFrontier(frontier, blockHeight, null);
63+
}
64+
65+
/**
66+
* Initializes a new tree from a CommitmentTree v0 frontier
67+
* (the {@code orchardTree} value from {@code z_gettreestate}), with a custom
68+
* checkpoint retention capacity.
69+
*
70+
* @param frontier raw CommitmentTree v0 bytes
71+
* @param blockHeight block height at which the frontier was captured (u32 range)
72+
* @param maxCheckpoints number of checkpoints to retain before pruning the oldest;
73+
* {@code null} to use the default (100)
74+
* @return initialized tree instance
75+
* @throws WasmException if the frontier is invalid
76+
*/
77+
public static ShieldedMerkleTree fromFrontier(byte[] frontier, long blockHeight, Integer maxCheckpoints) {
6278
Objects.requireNonNull(frontier, "frontier must not be null");
6379
requireU32(blockHeight, "blockHeight");
80+
if (maxCheckpoints != null) {
81+
requireU32(maxCheckpoints, "maxCheckpoints");
82+
}
6483
return create(bridge -> {
65-
byte[] reqBytes = FromFrontierRequest.newBuilder()
84+
FromFrontierRequest.Builder builder = FromFrontierRequest.newBuilder()
6685
.setFrontier(ByteString.copyFrom(frontier))
6786
// safe: requireU32 guarantees blockHeight is in [0, 0xFFFF_FFFF]
68-
.setBlockHeight((int) blockHeight)
69-
.build()
70-
.toByteArray();
71-
unwrapVoid(bridge.call("from_frontier", reqBytes));
87+
.setBlockHeight((int) blockHeight);
88+
if (maxCheckpoints != null) {
89+
builder.setMaxCheckpoints(maxCheckpoints);
90+
}
91+
unwrapVoid(bridge.call("from_frontier", builder.build().toByteArray()));
7292
});
7393
}
7494

packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTreeTest.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class ShieldedMerkleTreeTest {
2424
*/
2525
private static final TreeState EMPTY_STATE = new TreeState(
2626
"{\"shards\":[],\"cap\":{\"type\":\"Nil\"},\"checkpoints\":[],"
27-
+ "\"tip_height\":null,\"leaf_count\":0}");
27+
+ "\"tip_height\":null,\"leaf_count\":0,\"max_checkpoints\":100}");
2828

2929
/**
3030
* CommitmentTree v0 frontier encoding for a single-leaf tree.
@@ -102,6 +102,45 @@ void fromFrontier_setsCheckpointCountToOne() {
102102
}
103103
}
104104

105+
@Test
106+
void fromFrontier_withMaxCheckpoints_capsCheckpointRetention() {
107+
try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(FRONTIER, 1L, 2)) {
108+
tree.appendCommitments(2L, Collections.emptyList(), List.of(), null);
109+
tree.appendCommitments(3L, Collections.emptyList(), List.of(), null);
110+
tree.appendCommitments(4L, Collections.emptyList(), List.of(), null);
111+
112+
MerkleTreeInfo info = tree.getInfo();
113+
assertEquals(2, info.checkpointCount);
114+
115+
WasmException ex = assertThrows(WasmException.class, () -> tree.truncateToCheckpoint(1L));
116+
assertEquals("CHECKPOINT_NOT_FOUND", ex.getErrorCode());
117+
}
118+
}
119+
120+
@Test
121+
void fromFrontier_negativeMaxCheckpoints_throwsIllegalArgumentException() {
122+
assertThrows(IllegalArgumentException.class, () ->
123+
ShieldedMerkleTree.fromFrontier(FRONTIER, 1L, -1));
124+
}
125+
126+
@Test
127+
void saveAndLoad_preservesMaxCheckpoints() {
128+
TreeState savedState;
129+
try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(FRONTIER, 1L, 2)) {
130+
tree.appendCommitments(2L, Collections.emptyList(), List.of(), null);
131+
tree.appendCommitments(3L, Collections.emptyList(), List.of(), null);
132+
savedState = tree.save();
133+
}
134+
try (ShieldedMerkleTree restored = ShieldedMerkleTree.fromState(savedState)) {
135+
restored.appendCommitments(4L, Collections.emptyList(), List.of(), null);
136+
MerkleTreeInfo info = restored.getInfo();
137+
assertEquals(2, info.checkpointCount);
138+
139+
WasmException ex = assertThrows(WasmException.class, () -> restored.truncateToCheckpoint(1L));
140+
assertEquals("CHECKPOINT_NOT_FOUND", ex.getErrorCode());
141+
}
142+
}
143+
105144
// -------------------------------------------------------------------------
106145
// fromState
107146
// -------------------------------------------------------------------------

packages/wasm-privacy-coin/src/zcash/tree.rs

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pub struct PersistedShardTreeState {
6262
pub checkpoints: Vec<SerializedCheckpoint>,
6363
pub tip_height: Option<u32>,
6464
pub leaf_count: u64,
65+
pub max_checkpoints: usize,
6566
}
6667

6768
// ---------------------------------------------------------------------------
@@ -121,6 +122,7 @@ pub fn extract_state(
121122
tree: &ShieldedShardTree,
122123
tip_height: Option<u32>,
123124
leaf_count: u64,
125+
max_checkpoints: usize,
124126
) -> Result<PersistedShardTreeState, String> {
125127
let store = tree.store();
126128

@@ -175,6 +177,7 @@ pub fn extract_state(
175177
checkpoints,
176178
tip_height,
177179
leaf_count,
180+
max_checkpoints,
178181
})
179182
}
180183

@@ -212,7 +215,7 @@ pub fn restore_state(state: &PersistedShardTreeState) -> Result<ShieldedShardTre
212215
.map_err(|e| format!("add_checkpoint error: {:?}", e))?;
213216
}
214217

215-
Ok(ShardTree::new(store, MAX_CHECKPOINTS))
218+
Ok(ShardTree::new(store, state.max_checkpoints))
216219
}
217220

218221
// ---------------------------------------------------------------------------
@@ -321,6 +324,7 @@ pub struct OwnedTree {
321324
tree: ShieldedShardTree,
322325
tip_height: Option<u32>,
323326
leaf_count: u64,
327+
max_checkpoints: usize,
324328
}
325329

326330
impl OwnedTree {
@@ -334,11 +338,17 @@ impl OwnedTree {
334338
tree,
335339
tip_height: persisted.tip_height,
336340
leaf_count: persisted.leaf_count,
341+
max_checkpoints: persisted.max_checkpoints,
337342
})
338343
}
339344

340345
/// Initialize from a CommitmentTree v0 frontier (raw bytes, not hex-encoded).
341-
pub fn from_frontier(frontier: &[u8], block_height: u32) -> Result<Self, String> {
346+
pub fn from_frontier(
347+
frontier: &[u8],
348+
block_height: u32,
349+
max_checkpoints: Option<usize>,
350+
) -> Result<Self, String> {
351+
let max_checkpoints = max_checkpoints.unwrap_or(MAX_CHECKPOINTS);
342352
use incrementalmerkletree::frontier::NonEmptyFrontier;
343353

344354
let mut offset = 0;
@@ -395,7 +405,7 @@ impl OwnedTree {
395405

396406
let leaf_count = u64::from(nef.position()) + 1;
397407

398-
let mut tree = ShardTree::new(MemoryShardStore::empty(), MAX_CHECKPOINTS);
408+
let mut tree = ShardTree::new(MemoryShardStore::empty(), max_checkpoints);
399409
tree.insert_frontier_nodes(
400410
nef,
401411
Retention::Checkpoint {
@@ -409,12 +419,18 @@ impl OwnedTree {
409419
tree,
410420
tip_height: Some(block_height),
411421
leaf_count,
422+
max_checkpoints,
412423
})
413424
}
414425

415426
/// Serialize the tree state to bytes (UTF-8 JSON of `PersistedShardTreeState`).
416427
pub fn save(&self) -> Result<Vec<u8>, String> {
417-
let state = extract_state(&self.tree, self.tip_height, self.leaf_count)?;
428+
let state = extract_state(
429+
&self.tree,
430+
self.tip_height,
431+
self.leaf_count,
432+
self.max_checkpoints,
433+
)?;
418434
serde_json::to_vec(&state).map_err(|e| format!("JSON serialize error: {}", e))
419435
}
420436

@@ -540,7 +556,7 @@ mod tests {
540556
const F_CHECKPOINT_MARKED: u8 = 3;
541557

542558
fn empty_tree() -> OwnedTree {
543-
let json = r#"{"shards":[],"cap":{"type":"Nil"},"checkpoints":[],"tip_height":null,"leaf_count":0}"#;
559+
let json = r#"{"shards":[],"cap":{"type":"Nil"},"checkpoints":[],"tip_height":null,"leaf_count":0,"max_checkpoints":100}"#;
544560
OwnedTree::from_state(json.as_bytes()).expect("empty state")
545561
}
546562

@@ -582,7 +598,13 @@ mod tests {
582598
None,
583599
)
584600
.unwrap();
585-
let state = extract_state(&tree.tree, tree.tip_height, tree.leaf_count).unwrap();
601+
let state = extract_state(
602+
&tree.tree,
603+
tree.tip_height,
604+
tree.leaf_count,
605+
tree.max_checkpoints,
606+
)
607+
.unwrap();
586608

587609
assert_eq!(
588610
find_f_in_state(&state, cmx1_hex),
@@ -603,7 +625,13 @@ mod tests {
603625
let mut tree = empty_tree();
604626
tree.append_commitments(1, vec![cmx(1)], vec![false], None)
605627
.unwrap();
606-
let state = extract_state(&tree.tree, tree.tip_height, tree.leaf_count).unwrap();
628+
let state = extract_state(
629+
&tree.tree,
630+
tree.tip_height,
631+
tree.leaf_count,
632+
tree.max_checkpoints,
633+
)
634+
.unwrap();
607635

608636
assert_eq!(
609637
find_f_in_state(&state, cmx_hex),
@@ -619,4 +647,53 @@ mod tests {
619647
tree.append_commitments(1, vec![cmx(1), cmx(2)], vec![true, false, true], None);
620648
assert!(result.is_err());
621649
}
650+
651+
// -------------------------------------------------------------------------
652+
// max_checkpoints
653+
// -------------------------------------------------------------------------
654+
655+
fn frontier_bytes() -> Vec<u8> {
656+
hex::decode("0101000000000000000000000000000000000000000000000000000000000000000000")
657+
.unwrap()
658+
}
659+
660+
#[test]
661+
fn from_frontier_defaults_max_checkpoints_when_not_specified() {
662+
let tree = OwnedTree::from_frontier(&frontier_bytes(), 1, None).unwrap();
663+
assert_eq!(tree.max_checkpoints, MAX_CHECKPOINTS);
664+
}
665+
666+
#[test]
667+
fn from_frontier_uses_custom_max_checkpoints_when_specified() {
668+
let tree = OwnedTree::from_frontier(&frontier_bytes(), 1, Some(5)).unwrap();
669+
assert_eq!(tree.max_checkpoints, 5);
670+
}
671+
672+
#[test]
673+
fn max_checkpoints_enforced_evicts_oldest_checkpoint() {
674+
let mut tree = OwnedTree::from_frontier(&frontier_bytes(), 1, Some(2)).unwrap();
675+
tree.append_commitments(2, vec![], vec![], None).unwrap();
676+
tree.append_commitments(3, vec![], vec![], None).unwrap();
677+
tree.append_commitments(4, vec![], vec![], None).unwrap();
678+
679+
let (_, _, checkpoint_count) = tree.get_info().unwrap();
680+
assert_eq!(
681+
checkpoint_count, 2,
682+
"checkpoint_count should be capped at 2"
683+
);
684+
685+
let result = tree.truncate_to_checkpoint(1);
686+
assert!(
687+
result.is_err(),
688+
"oldest checkpoint should have been evicted"
689+
);
690+
}
691+
692+
#[test]
693+
fn save_round_trip_preserves_max_checkpoints() {
694+
let tree = OwnedTree::from_frontier(&frontier_bytes(), 1, Some(7)).unwrap();
695+
let bytes = tree.save().unwrap();
696+
let restored = OwnedTree::from_state(&bytes).unwrap();
697+
assert_eq!(restored.max_checkpoints, 7);
698+
}
622699
}

0 commit comments

Comments
 (0)