dig-rpc-protocol 0.7.0

Canonical DIG-node JSON-RPC protocol: request/response types, the method enum + tier classification, the error-code taxonomy, and an OpenRPC 1.2.6 document generator. The single source of truth both DIG node implementations depend on. Pure types — no I/O, no async, no server logic. (Formerly dig-rpc-types.)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
//! Request/response wire types for every DIG-node RPC method.
//!
//! Each type is `serde`-derived and models a method's params or result
//! field-for-field with the canonical implementation (the digstore `dig-node`
//! crate). Fields that appear only in one profile or only on the first window of
//! a paged stream are `Option` and doc-flagged.
//!
//! Hex-encoded identifiers (`store_id`, `root`, `retrieval_key`, `peer_id`) are
//! carried as `String` on the wire — lower-case 64-hex — because the interface
//! crate does no crypto and imposes no byte-array dependency. Callers validate
//! length/charset at their boundary.
//!
//! # Two content profiles, one chunk type
//!
//! [`ContentChunk`] models both the node profile (`dig.getContent` on the local
//! dig-node) and the network profile (`rpc.dig.net`). The network-profile-only
//! fields — [`total_length`](ContentChunk::total_length),
//! [`length`](ContentChunk::length), [`program_hash`](ContentChunk::program_hash),
//! [`offset`](ContentChunk::offset) — are `Option` so one type serves both
//! surfaces with no silent split.

use serde::{Deserialize, Serialize};

/// A lower-case 64-hex identifier on the wire (e.g. a `store_id`, `root`,
/// `retrieval_key`, or `peer_id`). A type alias for documentation; validation is
/// the boundary's job.
pub type HexId = String;

// ===========================================================================
// Shared value objects
// ===========================================================================

/// A peer's dialable network endpoint.
///
/// IPv6-first per the ecosystem networking rule: an address list orders
/// global-unicast IPv6 ahead of IPv4 fallback, and a wildcard bind
/// (`[::]`/`0.0.0.0`) is never advertised.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerAddress {
    /// The host — an IPv6 or IPv4 literal (never a wildcard).
    pub host: String,
    /// The TCP port.
    pub port: u16,
    /// How the address was discovered: `direct`, `reflexive`, `mapped`, or
    /// `relay`.
    pub kind: String,
}

/// A content provider: a holder's stable `peer_id` plus its candidate addresses.
///
/// The address list is byte-compatible with [`dig.getPeers`](crate::method::Method::GetPeers)
/// and the DHT provider shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Provider {
    /// The holder's stable `peer_id` = `SHA-256(TLS SPKI DER)`, 64-hex.
    pub peer_id: HexId,
    /// The holder's candidate addresses (IPv6-first).
    pub addresses: Vec<PeerAddress>,
}

/// The content item a redirect points at: `store_id` [+ `root` [+
/// `retrieval_key`]], each lower-case 64-hex — the exact item to re-request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentRef {
    /// The store launcher id (always present).
    pub store_id: HexId,
    /// The generation root (present for capsule/resource granularity).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The resource retrieval key (present for resource granularity).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub retrieval_key: Option<HexId>,
}

/// The `error.data.redirect` payload of a
/// [`ContentRedirect`](crate::error::ErrorCode::ContentRedirect) (`-32008`).
///
/// The node does not hold the content but located peers that do; the caller
/// re-requests against one of `providers`, echoing `redirect_depth` in its
/// params so the hop budget stays bounded (stop at `max_redirects`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RedirectInfo {
    /// The content the caller should re-request.
    pub content: ContentRef,
    /// The holders (peer_id + candidate addresses) to re-request against.
    pub providers: Vec<Provider>,
    /// The hop count the caller must echo on its re-request.
    pub redirect_depth: u64,
    /// The redirect budget — stop redirecting when `redirect_depth` reaches this.
    pub max_redirects: u64,
}

// ===========================================================================
// dig.getContent  (PUBLIC-READ, also peer-reachable)
// ===========================================================================

/// Params for [`dig.getContent`](crate::method::Method::GetContent) — a verified
/// resource-window read.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetContentParams {
    /// The CHIP-0035 singleton launcher id (64-hex).
    pub store_id: HexId,
    /// `SHA-256(urn)` — the only URN-derived value sent to a node (64-hex).
    pub retrieval_key: HexId,
    /// The generation root (64-hex). Empty / `"latest"` / absent ⇒ resolve the
    /// chain tip.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The window start offset (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// Retrieval mode: `"speed"` (default) or `"privacy"` (onion — target).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub mode: Option<String>,
    /// The redirect budget already consumed (echoed from a `-32008` redirect).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect_depth: Option<u64>,
}

/// One window of a resource's ciphertext — the chunk wire object.
///
/// Serves BOTH the node profile (`dig.getContent` on the local dig-node) and the
/// network profile (`rpc.dig.net`). Node-profile responses omit the
/// network-profile-only fields; the doc on each field says which profile
/// populates it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentChunk {
    /// This window's bytes, base64. Both profiles.
    pub ciphertext: String,
    /// The resolved generation root (64-hex). Both profiles.
    pub root: HexId,
    /// Whether this window ends the resource. Both profiles.
    pub complete: bool,
    /// The next offset; present iff not complete. Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub next_offset: Option<u64>,
    /// Whole-resource merkle proof, base64. First window only (`offset == 0`).
    /// Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub inclusion_proof: Option<String>,
    /// Per-chunk ciphertext lengths of the full resource. First window only;
    /// empty ⇒ single chunk. Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_lens: Option<Vec<u64>>,
    /// Where the window was served from: `"local"` (this device's cache) or
    /// `"remote"` (freshly fetched). **Node profile only** — additive tag the
    /// in-process node sets; absent on the network profile.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub source: Option<String>,
    /// The full resource ciphertext length (pre-windowing). **Network profile
    /// only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// This window's byte length. **Network profile only** (the node profile's
    /// length is implicit in `ciphertext`).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub length: Option<u64>,
    /// The window start offset (echoed). **Network profile only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// `SHA-256(.dig bytes)` — the on-chain program identity (64-hex).
    /// **Network profile only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub program_hash: Option<HexId>,
}

// ===========================================================================
// dig.getAnchoredRoot  (PUBLIC-READ, also peer-reachable)
// ===========================================================================

/// Params for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetAnchoredRootParams {
    /// The store launcher id (64-hex).
    pub store_id: HexId,
}

/// Result for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot) —
/// the store's current chain-anchored tip root.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnchoredRoot {
    /// The store launcher id (echoed, 64-hex).
    pub store_id: HexId,
    /// The chain-anchored tip root (64-hex).
    pub root: HexId,
}

// ===========================================================================
// dig.getCollection / dig.listCollectionItems  (PUBLIC-READ, also peer)
// ===========================================================================

/// Params for [`dig.getCollection`](crate::method::Method::GetCollection).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetCollectionParams {
    /// The NFT launcher ids to resolve. Capped at 10,000 (over-cap ⇒ `-32602`).
    pub launcher_ids: Vec<HexId>,
    /// The optional collection creator DID (64-hex).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub did: Option<HexId>,
}

/// Result for [`dig.getCollection`](crate::method::Method::GetCollection) —
/// collection-level facts computed from DIG's own coinset data.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Collection {
    /// The resolved creator DID (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub did: Option<HexId>,
    /// The DID declared by the caller / metadata (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub declared_did: Option<HexId>,
    /// The number of launcher ids requested.
    pub item_count: u64,
    /// How many resolved to live NFTs.
    pub resolved_count: u64,
    /// The uniform royalty in basis points, if resolvable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub royalty_basis_points: Option<u64>,
}

/// Params for
/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListCollectionItemsParams {
    /// The NFT launcher ids. Capped at 10,000 (over-cap ⇒ `-32602`).
    pub launcher_ids: Vec<HexId>,
    /// Page start (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// Page size (default 50, capped at 200).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub limit: Option<u64>,
}

/// CHIP-0007 NFT metadata for one collection item.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct NftMetadata {
    /// Edition ordinal, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub edition_number: Option<u64>,
    /// Edition total, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub edition_total: Option<u64>,
    /// Data URIs.
    #[serde(default)]
    pub data_uris: Vec<String>,
    /// `SHA-256` of the data (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub data_hash: Option<HexId>,
    /// Metadata URIs.
    #[serde(default)]
    pub metadata_uris: Vec<String>,
    /// `SHA-256` of the metadata document (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata_hash: Option<HexId>,
    /// License URIs.
    #[serde(default)]
    pub license_uris: Vec<String>,
    /// `SHA-256` of the license (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub license_hash: Option<HexId>,
}

/// One resolved collection item — its current on-chain owner, royalty, and
/// CHIP-0007 metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CollectionItem {
    /// The NFT launcher id (64-hex).
    pub launcher_id: HexId,
    /// The current coin id (64-hex).
    pub coin_id: HexId,
    /// The current owner DID (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub owner_did: Option<HexId>,
    /// The royalty puzzle hash (64-hex).
    pub royalty_puzzle_hash: HexId,
    /// The royalty in basis points.
    pub royalty_basis_points: u64,
    /// The current owner puzzle hash (64-hex).
    pub owner_puzzle_hash: HexId,
    /// The CHIP-0007 metadata, if resolvable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata: Option<NftMetadata>,
}

/// Result for
/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems) — a
/// page of resolved items.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CollectionItemsPage {
    /// This page's items.
    pub items: Vec<CollectionItem>,
    /// The page start (echoed).
    pub offset: u64,
    /// The page size (echoed).
    pub limit: u64,
    /// The total item count across the whole (capped) launcher set.
    pub total: u64,
    /// The next page's offset, or `null` when exhausted.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub next_offset: Option<u64>,
}

// ===========================================================================
// dig.getNetworkInfo  (PEER)
// ===========================================================================

/// The node's relay reservation posture.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RelayStatus {
    /// The relay endpoint URL (e.g. `wss://relay.dig.net:9450`).
    pub url: String,
    /// Whether a relay reservation is currently held.
    pub reserved: bool,
}

/// Result for [`dig.getNetworkInfo`](crate::method::Method::GetNetworkInfo) —
/// this node's own peer-network posture.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct NetworkInfo {
    /// This node's stable `peer_id` = `SHA-256(TLS SPKI DER)` (64-hex), or
    /// `null` when no identity is configured.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub peer_id: Option<HexId>,
    /// The DIG network id (e.g. `DIG_MAINNET`).
    pub network_id: String,
    /// The first advertised (dialable) candidate address, `host:port`.
    pub listen_addr: String,
    /// The STUN-discovered reflexive address, if known.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub reflexive_addr: Option<String>,
    /// All advertised candidate addresses (IPv6-first).
    pub candidate_addresses: Vec<String>,
    /// Reachability posture: `"direct"` or `"relayed"`.
    pub reachability: String,
    /// The relay reservation posture.
    pub relay: RelayStatus,
}

// ===========================================================================
// dig.getPeers  (PEER)
// ===========================================================================

/// Result for [`dig.getPeers`](crate::method::Method::GetPeers) — the peers this
/// node currently knows (peer exchange over RPC).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeersList {
    /// The known peers (peer_id + candidate addresses).
    pub peers: Vec<Provider>,
}

// ===========================================================================
// dig.announce  (PEER)
// ===========================================================================

/// Params for [`dig.announce`](crate::method::Method::Announce).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnnounceParams {
    /// The announcing peer's `peer_id` (64-hex).
    pub peer_id: HexId,
    /// The announcing peer's candidate addresses.
    pub addresses: Vec<PeerAddress>,
}

/// Result for [`dig.announce`](crate::method::Method::Announce).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnnounceAck {
    /// Whether the announcement was accepted.
    pub accepted: bool,
    /// How many peers this node now knows.
    pub known_peers: u64,
}

// ===========================================================================
// dig.getAvailability  (PEER)
// ===========================================================================

/// One availability query item. Granularity is inferred from which fields are
/// present: `store_id` only ⇒ which roots are held; `+root` ⇒ a capsule; `+root
/// +retrieval_key` ⇒ a resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityQuery {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root (64-hex), for capsule/resource granularity.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The resource retrieval key (64-hex), for resource granularity.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub retrieval_key: Option<HexId>,
}

/// Params for [`dig.getAvailability`](crate::method::Method::GetAvailability).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetAvailabilityParams {
    /// The items to check. Capped at 512 per batch (past-cap items are dropped).
    pub items: Vec<AvailabilityQuery>,
}

/// One availability answer. Only the fields relevant to the query's granularity
/// are populated.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityAnswer {
    /// Whether this node holds the queried item.
    pub available: bool,
    /// The roots held (store-granularity queries only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub roots: Option<Vec<HexId>>,
    /// The full resource ciphertext length (resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// The chunk count (resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_count: Option<u64>,
    /// Whether the whole item is held (root/resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub complete: Option<bool>,
    /// Providers that hold the item — present on a miss when holders were
    /// located (enriched answer).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub providers: Option<Vec<Provider>>,
}

/// Result for [`dig.getAvailability`](crate::method::Method::GetAvailability) —
/// one answer per query item, in order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityBatch {
    /// The per-item answers (index-aligned to the query items served).
    pub items: Vec<AvailabilityAnswer>,
}

// ===========================================================================
// dig.listInventory  (PEER)
// ===========================================================================

/// Params for [`dig.listInventory`](crate::method::Method::ListInventory).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListInventoryParams {
    /// The store to list roots for (64-hex). Absent ⇒ list all stores served.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub store_id: Option<HexId>,
    /// The maximum number of entries to return.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub limit: Option<u64>,
}

/// Result for [`dig.listInventory`](crate::method::Method::ListInventory).
///
/// With a `store_id` the node returns the roots it holds for that store; without
/// one it returns the stores it serves. `#[serde(untagged)]` keeps the wire flat
/// (`{"roots": …}` or `{"stores": …}`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub enum Inventory {
    /// The roots held for a specific store.
    ForStore {
        /// The store launcher id (echoed, 64-hex).
        store_id: HexId,
        /// The roots this node holds for the store.
        roots: Vec<HexId>,
    },
    /// The stores this node serves (no `store_id` given).
    AllStores {
        /// The store launcher ids served.
        stores: Vec<HexId>,
    },
}

// ===========================================================================
// dig.fetchRange  (PEER)
// ===========================================================================

/// Params for [`dig.fetchRange`](crate::method::Method::FetchRange) — a single
/// range frame of a resource this node holds.
///
/// # Construction
///
/// Like [`RangeFrame`], this type is `#[non_exhaustive]`: build it with
/// [`resource`](Self::resource) plus the `with_*` setters rather than a struct
/// literal, so a future additive field is a PATCH for every consumer instead of a
/// semver cascade.
///
/// # Cross-repo contract
///
/// [`skip_layout`](Self::skip_layout) is byte-identical to
/// `dig_nat::mux::RangeRequest::skip_layout`, pinned in
/// `tests/nat_wire_mirror.rs`. The two enclosing types deliberately differ in every
/// other respect — dig-nat's `RangeRequest` is a length-prefixed stream preamble,
/// this is a JSON-RPC params object with a `redirect_depth` dig-nat has no notion
/// of — so the byte-identical contract here is the FIELD, not the object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct FetchRangeParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root (64-hex, required for a resource fetch).
    pub root: HexId,
    /// `SHA-256(urn)` (64-hex, required for a resource fetch).
    pub retrieval_key: HexId,
    /// The range start (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// The range length in bytes (> 0; clamped to the window cap).
    pub length: u64,
    /// Whole-capsule mode (default false). Capsule range fetch is not yet
    /// served; a `true` here yields `-32004`.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub capsule: Option<bool>,
    /// The redirect budget already consumed (echoed from a `-32008` redirect).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect_depth: Option<u64>,
    /// Suppress the resource-scaling layout metadata (`chunk_lens` +
    /// `inclusion_proof`) on this stream's frames, because the client already holds
    /// the commitment for this `root`.
    ///
    /// A client that has already read the layout once — a resumed download, a second
    /// range of the same resource, a parallel fetch from another holder — does not
    /// need it again, and re-sending it costs a whole paged prologue PER STREAM: a
    /// 1,048,576-chunk layout is roughly 7.3 MB, which a 64-way parallel plan would
    /// otherwise pay 64 times over. Suppressing it is the difference between a
    /// bounded and an unbounded cost on the read path.
    ///
    /// Absent or `false` preserves the pre-0.6.0 behaviour, so an older holder that
    /// ignores this field is never broken by it — it simply sends metadata the client
    /// discards. Read the rule through
    /// [`suppresses_layout`](Self::suppresses_layout) rather than re-deriving it.
    ///
    /// The fixed-size identity fields ([`root`](RangeFrame::root),
    /// [`total_length`](RangeFrame::total_length),
    /// [`chunk_count`](RangeFrame::chunk_count),
    /// [`chunk_index`](RangeFrame::chunk_index)) are NOT suppressed: they are what
    /// detects a wrong-generation holder on arrival, and a client that stopped
    /// receiving them would lose that check on exactly the streams it fetches most.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skip_layout: Option<bool>,
}

impl FetchRangeParams {
    /// A range request for one content resource: `length` bytes of
    /// `retrieval_key`'s ciphertext at the generation `root`.
    pub fn resource(
        store_id: impl Into<HexId>,
        root: impl Into<HexId>,
        retrieval_key: impl Into<HexId>,
        length: u64,
    ) -> Self {
        FetchRangeParams {
            store_id: store_id.into(),
            root: root.into(),
            retrieval_key: retrieval_key.into(),
            offset: None,
            length,
            capsule: None,
            redirect_depth: None,
            skip_layout: None,
        }
    }

    /// Start the range at `offset` rather than at 0.
    pub fn with_offset(mut self, offset: u64) -> Self {
        self.offset = Some(offset);
        self
    }

    /// Request whole-capsule mode. Capsule range fetch is not yet served — a `true`
    /// here yields
    /// [`ResourceUnavailable`](crate::error::ErrorCode::ResourceUnavailable).
    pub fn with_capsule(mut self, capsule: bool) -> Self {
        self.capsule = Some(capsule);
        self
    }

    /// Echo the redirect budget already consumed, from a `-32008` redirect.
    pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
        self.redirect_depth = Some(redirect_depth);
        self
    }

    /// Ask the holder to omit the resource-scaling layout metadata, because this
    /// client already holds the commitment for this `root`. See
    /// [`skip_layout`](Self::skip_layout).
    pub fn with_skip_layout(mut self, skip_layout: bool) -> Self {
        self.skip_layout = Some(skip_layout);
        self
    }

    /// Whether this request suppresses the resource-scaling layout metadata.
    ///
    /// The single home for the "absent or `false` means SEND the layout" rule. A
    /// serve path that reached for `skip_layout.is_some()` instead would suppress the
    /// layout for a client that had explicitly asked for it — unrecoverable for that
    /// client, since the layout is a decrypt input it cannot obtain any other way on
    /// that stream.
    pub fn suppresses_layout(&self) -> bool {
        self.skip_layout.unwrap_or(false)
    }
}

/// One range frame of a resource: a byte window, plus the per-resource
/// verification metadata that makes the window independently checkable.
///
/// The metadata splits in two by whether it scales with the resource, and the
/// split decides which frames carry it:
///
/// - **The identity set — [`root`](Self::root),
///   [`total_length`](Self::total_length), [`chunk_count`](Self::chunk_count),
///   plus [`chunk_index`](Self::chunk_index) when the window begins on a chunk
///   boundary — rides EVERY frame.** It is fixed-size, so carrying it everywhere
///   costs a bounded number of bytes, and it is what lets a client fetching in
///   parallel from many holders reject a wrong-generation or wrong-layout source
///   the moment a frame arrives, rather than after paying for the whole resource
///   in bandwidth.
/// - **The resource-scaling set — [`chunk_lens`](Self::chunk_lens) and
///   [`inclusion_proof`](Self::inclusion_proof) — rides the first frame, or a
///   paged prologue, once per range stream.** Repeating it per frame would cost
///   proportionally to the resource against a frame budget with no slack; a layout
///   too large to state on one frame is paged instead, each page stamped with the
///   [`chunk_lens_offset`](Self::chunk_lens_offset) it begins at.
///
/// The window is exactly the span the caller requested — never widened.
///
/// # Construction
///
/// This type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html):
/// build it with [`data`](Self::data) and the `with_*` setters rather than a struct
/// literal. That is deliberate — the wire form grows as the protocol does, and
/// routing construction through named setters means a future additive field is a
/// PATCH release for every consumer instead of another semver cascade. It also
/// makes the two frame shapes different call chains rather than one call with a
/// pile of `None`s, so a continuation frame cannot accidentally claim a layout it
/// is not stating.
///
/// # Cross-repo contract
///
/// The wire form is **byte-identical** to `dig_nat::mux::RangeFrame`, the
/// streaming implementation of this frame (`SYSTEM.md` → "Canonical DIG-node RPC
/// interface"). Field names, encodings, and the population rule above are pinned
/// against dig-nat's actual output in `tests/nat_wire_mirror.rs`; a change to any
/// of them lands in both crates in the same unit of work or not at all.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct RangeFrame {
    /// The window start offset (echoed).
    pub offset: u64,
    /// This window's byte length.
    pub length: u64,
    /// This window's ciphertext, base64.
    pub bytes: String,
    /// Whether this frame ends the resource.
    pub complete: bool,
    /// The full resource ciphertext length. Part of the fixed-size **identity
    /// set**, so it rides EVERY frame.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// Per-chunk ciphertext lengths of the full resource, in order — the layout a
    /// reader needs before it can decrypt (per-chunk AEAD needs the WHOLE array,
    /// and a reader rejects an array whose sum differs from
    /// [`total_length`](Self::total_length)).
    ///
    /// Resource-scaling, so it rides the first frame or a **paged prologue**, once
    /// per range stream — never repeated on continuation frames. When paged, this
    /// is one page of the array and
    /// [`chunk_lens_offset`](Self::chunk_lens_offset) states the entry it begins
    /// at.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_lens: Option<Vec<u64>>,
    /// This frame's first chunk index — the pre-existing alias of
    /// [`first_chunk_index`](Self::first_chunk_index), carrying the same value, and
    /// the name dig-nat emits.
    ///
    /// Part of the **identity set**: it rides every frame whose window begins on a
    /// chunk boundary, and is OMITTED (rather than guessed) on a mid-chunk window.
    /// Being fixed-size, it is settable on its own — see
    /// [`with_chunk_index`](Self::with_chunk_index) — precisely so a continuation
    /// frame can state it without dragging along the once-per-stream
    /// [`inclusion_proof`](Self::inclusion_proof).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_index: Option<u64>,
    /// Whole-resource merkle proof against [`root`](Self::root), base64, relayed
    /// verbatim.
    ///
    /// Resource-scaling, so it rides the first frame or the paged prologue, once
    /// per range stream. A holder MUST NOT repeat it per frame: it is bounded at
    /// 4,096 base64 bytes, which against the frame budget leaves no slack for the
    /// payload the frame exists to carry.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub inclusion_proof: Option<String>,
    /// The chain-anchored root (64-hex) this frame's resource verified against.
    /// Part of the fixed-size **identity set**, so it rides EVERY frame.
    ///
    /// NOT A TRUST ANCHOR BY ITSELF. The client resolves the resource's root from
    /// the URN (chain-anchored) and PINS it before fetching; a peer-declared value
    /// never replaces that pinned root. What this field provides is a
    /// generation-CONSISTENCY check: a frame declaring a root other than the pinned
    /// one is REJECTED and attributed to the offending peer (NC-9 fail-closed). So
    /// a declared root can only ever cause rejection — it can never move the pinned
    /// root, and never makes an unverified frame acceptable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// **RESERVED — not currently derivable; a server MUST NOT emit it.**
    ///
    /// Per-chunk merkle inclusion proofs for the chunks a frame covers. No such
    /// proof exists in the current store format: the generation root's merkle
    /// leaves are per-RESOURCE (a leaf is the SHA-256 of a resource's WHOLE
    /// ciphertext), so a single chunk has no leaf to prove. A client MUST NOT
    /// require this field, and per-range verification instead uses the
    /// whole-resource [`inclusion_proof`](Self::inclusion_proof) together with the
    /// per-frame [`root`](Self::root)/[`chunk_lens`](Self::chunk_lens) metadata.
    ///
    /// Making it derivable requires a per-resource chunk-level commitment in the
    /// store format first (tracked as `dig_ecosystem#1601`). The field is kept in
    /// the wire type, unused, so populating it later is additive (§5.1); each entry
    /// would be an opaque base64 proof blob, since this pure level-00 wire type
    /// MUST NOT depend on the merkle primitive.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub range_proof: Option<Vec<String>>,
    /// The chunk index of the first chunk in this frame (0-based, into the
    /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
    ///
    /// Present only when the frame's window begins EXACTLY on a chunk boundary; a
    /// mid-chunk window omits it rather than assert an index the caller's own
    /// alignment check would contradict. The served window is exactly the requested
    /// span — a server MUST NOT widen a range to a chunk boundary — so a frame is
    /// chunk-aligned only when the caller asked for an aligned span.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub first_chunk_index: Option<u64>,
    /// The resource's TOTAL chunk count — how many entries the fully reassembled
    /// [`chunk_lens`](Self::chunk_lens) array has.
    ///
    /// Fixed-size, so it belongs to the **identity set** and rides EVERY frame.
    /// Together with [`root`](Self::root) and
    /// [`total_length`](Self::total_length) it is what lets a reader detect a
    /// wrong-generation or wrong-layout holder on the first frame it receives. It is
    /// also how a reader sizes the array it is paging in, and therefore how it knows
    /// a **paged prologue** is complete: the prologue ends when the reader holds
    /// `chunk_count` entries, which no single page can tell it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chunk_count: Option<u64>,
    /// The index into the resource's [`chunk_lens`](Self::chunk_lens) array at which
    /// THIS frame's page begins — how a **paged prologue** is located and
    /// reassembled.
    ///
    /// A resource whose layout exceeds the per-frame entry cap cannot state it on
    /// one frame, so the sender pages it: successive frames each carry up to that
    /// many entries, stamped with the offset they start at. A reader places each page
    /// at its offset and holds the whole array once it has
    /// [`chunk_count`](Self::chunk_count) entries.
    ///
    /// Absent means "this frame's `chunk_lens`, if any, begins at entry 0" — the
    /// single-frame layout, which is the shape every pre-0.6.0 producer emits. So an
    /// older frame decodes with exactly its original meaning (§5.1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chunk_lens_offset: Option<u64>,
}

impl RangeFrame {
    /// A **data frame**: `length` bytes of base64 ciphertext at `offset`, carrying
    /// no metadata — the bare shape every continuation frame starts from.
    ///
    /// `length` is stated rather than derived because [`bytes`](Self::bytes) is
    /// already base64 on this type, and recovering the raw window length from it
    /// would need a base64 codec this pure level-00 wire crate deliberately does not
    /// depend on. A serve path passes the length it served.
    pub fn data(offset: u64, length: u64, bytes: impl Into<String>) -> Self {
        RangeFrame {
            offset,
            length,
            bytes: bytes.into(),
            complete: false,
            total_length: None,
            chunk_lens: None,
            chunk_index: None,
            inclusion_proof: None,
            root: None,
            range_proof: None,
            first_chunk_index: None,
            chunk_count: None,
            chunk_lens_offset: None,
        }
    }

    /// Mark this as the final frame of the range.
    pub fn with_complete(mut self, complete: bool) -> Self {
        self.complete = complete;
        self
    }

    /// The fixed-size **identity set** every frame of a range carries: the
    /// generation `root` (64-hex) the range is served from, the resource's
    /// ciphertext `total_length`, and its `chunk_count`.
    ///
    /// These three are what let a reader reject a wrong-generation or wrong-layout
    /// holder the moment a frame arrives — which the resource-scaling metadata never
    /// could, since it arrives once. Call this on every frame.
    pub fn with_identity(
        mut self,
        root: impl Into<HexId>,
        total_length: u64,
        chunk_count: u64,
    ) -> Self {
        self.root = Some(root.into());
        self.total_length = Some(total_length);
        self.chunk_count = Some(chunk_count);
        self
    }

    /// State [`chunk_index`](Self::chunk_index) — the chunk this frame's window
    /// begins on — for a chunk-aligned window.
    ///
    /// Separate from [`with_inclusion_proof`](Self::with_inclusion_proof) on purpose:
    /// the index is fixed-size identity metadata that rides every aligned frame,
    /// while the proof is once-per-stream, so binding them together would force a
    /// producer to either repeat a proof it MUST NOT repeat or bypass this API. Omit
    /// the call entirely for a mid-chunk window.
    pub fn with_chunk_index(mut self, chunk_index: u64) -> Self {
        self.chunk_index = Some(chunk_index);
        self
    }

    /// Additionally state [`first_chunk_index`](Self::first_chunk_index), this
    /// crate's v0.4.0 alias of [`chunk_index`](Self::chunk_index).
    ///
    /// Both names carry the same value. dig-nat emits only `chunk_index`, so
    /// [`with_chunk_index`](Self::with_chunk_index) alone is the interoperable
    /// choice; a producer serving readers that expect the newer name states both.
    pub fn with_first_chunk_index(mut self, first_chunk_index: u64) -> Self {
        self.first_chunk_index = Some(first_chunk_index);
        self
    }

    /// One page of the resource's `chunk_lens` array, beginning at entry
    /// `chunk_lens_offset`.
    ///
    /// Call it once with offset `0` for a layout that fits a single frame, or once
    /// per page of a **paged prologue**. A page is only ever useful as part of a
    /// complete set: `chunk_lens` is a decrypt input, and a reader needs all
    /// [`chunk_count`](Self::chunk_count) entries before it can decrypt anything.
    pub fn with_chunk_lens_page(mut self, chunk_lens_offset: u64, chunk_lens: Vec<u64>) -> Self {
        self.chunk_lens_offset = Some(chunk_lens_offset);
        self.chunk_lens = Some(chunk_lens);
        self
    }

    /// The whole-resource merkle inclusion proof against
    /// [`root`](Self::root) (base64, relayed verbatim).
    ///
    /// Resource-scaling: state it on the first frame or the prologue, once per range
    /// stream, never per frame.
    pub fn with_inclusion_proof(mut self, inclusion_proof: impl Into<String>) -> Self {
        self.inclusion_proof = Some(inclusion_proof.into());
        self
    }

    /// State the **RESERVED** [`range_proof`](Self::range_proof) field.
    ///
    /// A server MUST NOT emit it — no per-chunk proof is derivable from the current
    /// store format (see the field's own documentation). The setter exists so the
    /// shape stays constructible for the conformance vectors that pin it, and so no
    /// field of this `#[non_exhaustive]` type is unreachable; it is not a serve-path
    /// call.
    pub fn with_range_proof(mut self, range_proof: Vec<String>) -> Self {
        self.range_proof = Some(range_proof);
        self
    }
}

// ===========================================================================
// dig.getModuleInfo / dig.fetchModuleRange  (PEER — whole-module pull, #1576)
// ===========================================================================

/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
/// handshake a peer reads before range-pulling a whole `.dig` module for
/// `(store, root)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetModuleInfoParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root whose `.dig` module is being pulled (64-hex, required).
    pub root: HexId,
}

/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
/// transfer descriptor of a whole `.dig` module.
///
/// The whole-module blob is content-addressed + immutable (the `.dig` container
/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
/// content id of the assembled blob; a puller verifies each pulled range against
/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
/// assembled module against its chain-anchored root before admitting + resharing
/// (NC-9 verified-content-not-safe-content).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ModuleInfo {
    /// The total byte length of the whole `.dig` module blob.
    pub total_size: u64,
    /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
    /// module bytes). The puller checks the assembled blob against this.
    pub module_hash: HexId,
    /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
    /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
    /// (the trailing chunk may be short). A puller checks each pulled
    /// [`RangeFrame`] against the covering entries for per-source attribution on a
    /// multi-source pull (a tampered range fails closed before assembly).
    pub chunk_hashes: Vec<HexId>,
    /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
    /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
    /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
    pub chunk_lens: Vec<u64>,
}

/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
/// a single range frame of the whole `.dig` module blob for `(store, root)`.
///
/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
/// echoes the whole-module size on the first frame, and
/// [`complete`](RangeFrame::complete) ends the stream.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FetchModuleRangeParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root whose `.dig` module is being pulled (64-hex, required).
    pub root: HexId,
    /// The range start into the module blob (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// The range length in bytes (> 0; clamped to the window cap).
    pub length: u64,
}

// ===========================================================================
// dig.stage  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
/// folder into a capsule `.dig` module in-process.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct StageParams {
    /// The absolute path to the folder to compile.
    pub dir: String,
    /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
    /// content-derived id (a preview).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub store_id: Option<HexId>,
    /// The store salt (64-hex). Present ⇒ a private store.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub salt: Option<HexId>,
    /// Optional DIGHub-style manifest metadata to embed.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata: Option<serde_json::Value>,
}

/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct StageResult {
    /// The canonical capsule identity, `storeId:rootHash`.
    pub capsule: String,
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The compiled generation root (64-hex).
    pub root: HexId,
    /// The filesystem path to the compiled `.dig` module.
    pub module_path: String,
    /// The module size in bytes.
    pub size: u64,
    /// The `chia://storeId:rootHash/` content address.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub content_address: Option<String>,
    /// The relative paths compiled into the capsule.
    #[serde(default)]
    pub files: Vec<String>,
    /// Whether this is an ephemeral preview (not advancing a real store).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub ephemeral: Option<bool>,
}

// ===========================================================================
// cache.*  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
///
/// The canonical field name for the cache path is `cache_dir` everywhere (the
/// shell's historical `dir` is unified onto this name).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CacheConfig {
    /// The on-disk cache size cap in bytes (floored at 64 MiB).
    pub cap_bytes: u64,
    /// The bytes currently used.
    pub used_bytes: u64,
    /// The effective resolved cache directory.
    pub cache_dir: String,
    /// Whether that directory is the canonical shared location (vs a
    /// process-private fallback).
    pub shared: bool,
}

/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SetCapBytesParams {
    /// The requested cap in bytes (floored at 64 MiB by the node).
    pub cap_bytes: u64,
}

/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SetCapBytesResult {
    /// The effective cap after flooring.
    pub cap_bytes: u64,
}

/// One durable cached-module entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CachedCapsule {
    /// The canonical capsule identity, `storeId:rootHash`.
    pub capsule: String,
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The generation root (64-hex).
    pub root: HexId,
    /// The module size in bytes.
    pub size_bytes: u64,
    /// When the module was last used (unix ms).
    pub last_used_unix_ms: u64,
}

/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CachedList {
    /// The cached capsules.
    pub cached: Vec<CachedCapsule>,
}

/// Params for a capsule-keyed cache op
/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CapsuleKey {
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The generation root (64-hex).
    pub root: HexId,
}

/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RemoveCachedResult {
    /// Whether an entry was removed.
    pub removed: bool,
}

/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
///
/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
/// caller can show it without treating it as a transport error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FetchAndCacheResult {
    /// `"cached"`, `"already_cached"`, or `"failed"`.
    pub status: String,
    /// The fetched module size in bytes (on success).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub size_bytes: Option<u64>,
    /// The served generation root (64-hex, on success).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub served_root: Option<HexId>,
    /// The failure message (on `status = "failed"`).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub message: Option<String>,
}

// ===========================================================================
// control.peerStatus  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
/// a snapshot of the node's L7 peer network. Always safe to call; reports
/// `running: false` on the FFI path.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerStatusSnapshot {
    /// Whether a peer network is currently active.
    pub running: bool,
    /// This node's `peer_id` (64-hex), if a peer network is running.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub peer_id: Option<HexId>,
    /// The DIG network id.
    pub network_id: String,
    /// The relay reservation posture.
    pub relay: RelayStatus,
    /// The number of currently connected peers.
    pub connected_peers: u64,
    /// The last peer-network error, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub last_error: Option<String>,
}

// ===========================================================================
// cache.stats  (CONTROL — loopback / in-process only)
// ===========================================================================

/// The decoded-content cache hit/miss counters carried in
/// [`CacheStats`](CacheStats::content_cache).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentCacheCounters {
    /// Session decoded-content cache hits.
    pub hits: u64,
    /// Session decoded-content cache misses.
    pub misses: u64,
}

/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
/// the reserved cap + live usage, the cached-capsule count + total on-disk
/// bytes, and the session eviction + content-cache counters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CacheStats {
    /// The on-disk cache size cap in bytes.
    pub cap_bytes: u64,
    /// The bytes currently used on disk.
    pub used_bytes: u64,
    /// The number of durable cached capsules.
    pub entry_count: u64,
    /// The total on-disk bytes across the cached capsules.
    pub total_bytes: u64,
    /// Capsules evicted this session.
    pub evicted_count: u64,
    /// Bytes evicted this session.
    pub evicted_bytes: u64,
    /// The decoded-content cache hit/miss counters.
    pub content_cache: ContentCacheCounters,
}

// ===========================================================================
// control.subscribe / control.unsubscribe / control.listSubscriptions
// (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscribeParams {
    /// The store launcher id to (un)subscribe (64-hex).
    pub store_id: HexId,
}

/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscribeResult {
    /// Always `true` — the store is subscribed after this call.
    pub subscribed: bool,
    /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
    pub added: bool,
    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
    pub store_id: HexId,
}

/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct UnsubscribeResult {
    /// Always `false` — the store is not subscribed after this call.
    pub subscribed: bool,
    /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
    pub removed: bool,
    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
    pub store_id: HexId,
}

/// Result for
/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscriptionsList {
    /// The persisted subscribed store ids (64-hex each).
    pub subscriptions: Vec<HexId>,
    /// The subscription count (`subscriptions.len()`).
    pub count: u64,
}

// ===========================================================================
// control.peers.connect / control.peers.disconnect
// (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerConnectParams {
    /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
    /// (64-hex) to resolve an already-connected peer.
    pub peer: String,
}

/// Result for
/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerConnectResult {
    /// Always `true` on success — the peer is a counted, connected pool member.
    pub connected: bool,
    /// The connected peer's stable `peer_id` (64-hex).
    pub peer_id: HexId,
}

/// Result for
/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
///
/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerDisconnectResult {
    /// Always `true` — the peer is not in the pool after this call.
    pub disconnected: bool,
    /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
    pub peer_id: HexId,
}

// ===========================================================================
// dig.health / dig.methods / rpc.discover  (discovery)
// ===========================================================================

/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
/// capability summary.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Health {
    /// Liveness — `"ok"` when the node can serve.
    pub status: String,
    /// The node's software version.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub version: Option<String>,
    /// The DIG network id the node serves.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub network_id: Option<String>,
    /// The method names this node implements (its profile).
    #[serde(default)]
    pub methods: Vec<String>,
}

/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
/// this node implements (agent self-describe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Methods {
    /// The implemented method names.
    pub methods: Vec<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **Proves:** `ContentChunk` round-trips a node-profile window (no
    /// network-profile fields) without inventing keys.
    /// **Catches:** a missing `skip_serializing_if` that would leak `null`
    /// network-profile fields onto the node profile.
    #[test]
    fn content_chunk_node_profile_is_lean() {
        let c = ContentChunk {
            ciphertext: "AAA=".into(),
            root: "ab".repeat(32),
            complete: false,
            next_offset: Some(3_145_728),
            inclusion_proof: Some("cHJvb2Y=".into()),
            chunk_lens: Some(vec![10, 20]),
            source: Some("local".into()),
            total_length: None,
            length: None,
            offset: None,
            program_hash: None,
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["source"], "local");
        assert!(
            v.get("total_length").is_none(),
            "node profile must omit total_length"
        );
        assert!(v.get("program_hash").is_none());
        assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
    }

    /// **Proves:** the network-profile fields serialize when present.
    #[test]
    fn content_chunk_network_profile_carries_extras() {
        let c = ContentChunk {
            ciphertext: "AAA=".into(),
            root: "cd".repeat(32),
            complete: true,
            next_offset: None,
            inclusion_proof: None,
            chunk_lens: None,
            source: None,
            total_length: Some(100),
            length: Some(100),
            offset: Some(0),
            program_hash: Some("ef".repeat(32)),
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["total_length"], 100);
        assert_eq!(v["length"], 100);
        assert!(v.get("source").is_none());
    }

    /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
    /// shape.
    /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
    #[test]
    fn inventory_untagged_by_shape() {
        let for_store = Inventory::ForStore {
            store_id: "ab".repeat(32),
            roots: vec!["cd".repeat(32)],
        };
        let s = serde_json::to_string(&for_store).unwrap();
        assert!(s.contains("\"roots\""));
        assert!(!s.contains("ForStore"));
        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);

        let all = Inventory::AllStores {
            stores: vec!["ef".repeat(32)],
        };
        let s = serde_json::to_string(&all).unwrap();
        assert!(s.contains("\"stores\""));
        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
    }

    /// **Proves:** `RedirectInfo` serializes the full redirect payload the
    /// `-32008` envelope carries.
    #[test]
    fn redirect_info_shape() {
        let r = RedirectInfo {
            content: ContentRef {
                store_id: "ab".repeat(32),
                root: Some("cd".repeat(32)),
                retrieval_key: Some("ef".repeat(32)),
            },
            providers: vec![Provider {
                peer_id: "12".repeat(32),
                addresses: vec![PeerAddress {
                    host: "::1".into(),
                    port: 9444,
                    kind: "direct".into(),
                }],
            }],
            redirect_depth: 1,
            max_redirects: 4,
        };
        let v = serde_json::to_value(&r).unwrap();
        assert_eq!(v["redirect_depth"], 1);
        assert_eq!(v["max_redirects"], 4);
        assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
        assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
    }

    /// **Proves:** `cache.stats` models the live dig-node result field-for-field
    /// (the nested `content_cache{hits,misses}` object included).
    /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
    #[test]
    fn cache_stats_wire_shape() {
        let s = CacheStats {
            cap_bytes: 1 << 30,
            used_bytes: 2048,
            entry_count: 3,
            total_bytes: 2048,
            evicted_count: 1,
            evicted_bytes: 512,
            content_cache: ContentCacheCounters { hits: 7, misses: 2 },
        };
        let v = serde_json::to_value(s).unwrap();
        assert_eq!(v["cap_bytes"], 1 << 30);
        assert_eq!(v["entry_count"], 3);
        assert_eq!(v["content_cache"]["hits"], 7);
        assert_eq!(v["content_cache"]["misses"], 2);
        assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
    }

    /// **Proves:** the subscription-management results carry the exact
    /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
    /// the live node returns.
    #[test]
    fn subscription_result_shapes() {
        let sub = SubscribeResult {
            subscribed: true,
            added: true,
            store_id: "ab".repeat(32),
        };
        let v = serde_json::to_value(&sub).unwrap();
        assert_eq!(v["subscribed"], true);
        assert_eq!(v["added"], true);
        assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);

        let unsub = UnsubscribeResult {
            subscribed: false,
            removed: true,
            store_id: "cd".repeat(32),
        };
        let v = serde_json::to_value(&unsub).unwrap();
        assert_eq!(v["subscribed"], false);
        assert_eq!(v["removed"], true);
        assert_eq!(
            serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
            unsub
        );

        let list = SubscriptionsList {
            subscriptions: vec!["ef".repeat(32)],
            count: 1,
        };
        let v = serde_json::to_value(&list).unwrap();
        assert_eq!(v["count"], 1);
        assert_eq!(
            serde_json::from_value::<SubscriptionsList>(v).unwrap(),
            list
        );
    }

    /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
    /// round-trips with unknown future fields.
    /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
    /// to map a fetched byte range to its covering chunk hash.
    /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
    /// `chunk_hashes` and must sum to `total_size`.
    #[test]
    fn module_info_chunk_lens_shape() {
        let info = ModuleInfo {
            total_size: 1024,
            module_hash: "ab".repeat(32),
            chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
            chunk_lens: vec![512, 512],
        };
        let v = serde_json::to_value(&info).unwrap();
        assert_eq!(v["total_size"], 1024);
        assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
        assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
        assert_eq!(v["chunk_lens"][0], 512);
        assert_eq!(v["chunk_lens"][1], 512);
        assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
    }

    /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
    /// This is a REQUIRED field (not optional) — omitting it from the wire is a
    /// protocol violation and must fail-closed.
    #[test]
    fn module_info_rejects_missing_chunk_lens() {
        let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
        let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
        assert!(
            result.is_err(),
            "ModuleInfo must reject JSON missing the required chunk_lens field"
        );
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("chunk_lens"),
            "error message should mention chunk_lens: {}",
            err
        );
    }

    /// **Proves:** the peer connect/disconnect params + results round-trip and
    /// match the node's `{connected|disconnected, peer_id}` shapes.
    #[test]
    fn peer_connect_disconnect_shapes() {
        let p = PeerConnectParams {
            peer: "12".repeat(32),
        };
        let v = serde_json::to_value(&p).unwrap();
        assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);

        let c = PeerConnectResult {
            connected: true,
            peer_id: "12".repeat(32),
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["connected"], true);
        assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);

        let d = PeerDisconnectResult {
            disconnected: true,
            peer_id: "34".repeat(32),
        };
        let v = serde_json::to_value(&d).unwrap();
        assert_eq!(v["disconnected"], true);
        assert_eq!(
            serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
            d
        );
    }

    /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
    /// **Catches:** a regression to the shell's historical `dir` name.
    #[test]
    fn cache_config_field_name_is_cache_dir() {
        let c = CacheConfig {
            cap_bytes: 1 << 30,
            used_bytes: 0,
            cache_dir: "/var/cache/dig".into(),
            shared: true,
        };
        let v = serde_json::to_value(&c).unwrap();
        assert!(v.get("cache_dir").is_some());
        assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
    }
}