From 0d1d5d3e0850ace2534874356c1a879d18f786f5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 25 May 2026 19:48:41 +0200 Subject: [PATCH 01/20] feat!: add time-range indexes for trending/leaderboard queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `timeRange` index transform that buckets a timestamp index property (e.g. $createdAt) into fixed-length, regularly-spaced, optionally overlapping ranges. A document is indexed under every range whose window contains its timestamp, enabling provable "trending"/leaderboard queries (ORDER BY COUNT(*) within the most-recent range). The window parameters (`range`, `step`, `origin`) are declared in SECONDS. The finest meaningful granularity is a block: the target interval is 5s and `IN_TIME_RANGE` resolves its bucket from the committed block time, so sub-second windows would be false precision. Bucket starts, index keys and everything compared against a document's timestamp remain milliseconds, because the source fields are millisecond timestamps; the transform converts at its `range_ms()` / `step_ms()` / `origin_ms()` accessors, and contract validation rejects parameters too large to scale. The overlap factor (range / step) is capped by a versioned system limit (`SystemLimits::max_time_range_overlap_factor`, 24 at protocol version 14 — a day-long window sliding hourly), enforced at contract registration rather than at parse: the factor is the index's per-document write amplification, so retuning it is a protocol-version decision. The cost-estimation fan-out clamp reads the same limit. - rs-dpp: TimeRangeTransform on Index/IndexLevel, JSON parsing + meta-schema, validation (range % step, overlap cap, first-property, timestamp source, non-contested/null-searchable/non-ranked, cross-index consistency), update-immutability, bucket math - rs-drive: insert/delete/update index fan-out (one document -> N overlapping bucket entries); time-range query resolution to a concrete bucket equality - dapi-grpc: new v1 IN_TIME_RANGE where operator (v0 wire unchanged), regenerated JS/web/Obj-C/Python clients - drive-abci: v1 handler resolves IN_TIME_RANGE from authoritative block time - rs-sdk/wasm-sdk: with_time_range / timeRange query builders; proof verification (documents AND count/sum/avg aggregates) re-derives the bucket from the quorum-signed response metadata time Index selection is provenance-pinned. The resolved clause is an ordinary equality, indistinguishable from a hand-written raw-timestamp lookup, so resolution records its field in the query's `resolved_time_range_fields` (never parsed from the wire) and every index picker — `find_best_index` and the count/sum/average pickers — admits a bucketed index only for a query that resolved exactly its source field, and never for a raw query (ranked indexes exclude transforms outright, and a contract cannot declare ranked keywords on a bucketed index — the secondaries would be maintained but unservable). The aggregate dispatchers and SDK verifiers also reject provenance attached to any clause shape but the single resolved equality, so per-In-value fan-out can never present raw values as resolved bucket starts. Either mismatch is silent otherwise: a bucket-start equality matched against raw timestamps (or a raw timestamp against bucket starts) proves an empty result, and counting or summing across bucket keys multi-counts a document once per overlapping bucket, all with valid proofs since the verifier re-runs the same selection. Two resolved fields are rejected — a transform's source must be its index's first property, so no single index can serve them. The source must be a required system timestamp ($createdAt / $updatedAt / $transferredAt): no user property type parses to a millisecond timestamp (the schema grammar has no "date" type and `format: "date-time"` stays a string), so user-defined sources are rejected at contract validation until such a representation exists. Unique time-range indexes are supported for non-overlapping windows (range == step) with $createdAt as the source — "at most one document per window per remaining key tuple", e.g. one report per author per day. The uniqueness probe rewrites the source equality to the containing bucket start (recording it as resolved provenance so index pinning admits the bucketed index), pre-origin timestamps skip the check (they are never indexed, so they cannot collide), and the update walker handles both terminator layouts. Overlapping windows stay incompatible with uniqueness (one document occupies several bucket keys), and mutable sources ($updatedAt / $transferredAt) are rejected because the uniqueness validator's changed-tuple reasoning has no old-bucket tracking; $createdAt is immutable across updates, so the bucket component of the tuple never moves. Gating: part of the meta-schema-v3 grammar (protocol version 14), alongside the ranked index keywords — the `timeRange` keyword is admitted by parser generation 3 only and falls through to the unknown-key rejection below that, and the storage fan-out lives in the PV14 walkers (insert/delete v2, update v1). Pre-origin timestamps belong to no bucket: they produce no index entries, and resolving a selector before the origin is a query error. Null timestamps keep a single ordinary null entry across insert, delete and the update set-diff. The update path emits its insertions before its delete-up-tree operations so the emptiness walk sees the batch's own re-inserts (suffix changes at an unchanged timestamp were otherwise emitting a delete of a tree the same batch populates). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Fable 5 --- .../clients/drive/v0/nodejs/drive_pbjs.js | 7 + .../platform/v0/nodejs/platform_pbjs.js | 7 + .../platform/v0/nodejs/platform_protoc.js | 3 +- .../platform/v0/objective-c/Platform.pbobjc.h | 13 + .../platform/v0/objective-c/Platform.pbobjc.m | 4 +- .../platform/v0/python/platform_pb2.py | 1291 +++++++++-------- .../clients/platform/v0/web/platform_pb.d.ts | 1 + .../clients/platform/v0/web/platform_pb.js | 3 +- .../protos/platform/v0/platform.proto | 10 + .../src/documents/average_proof_helpers.rs | 44 +- .../src/documents/count_proof_helpers.rs | 329 ++++- .../src/documents/document_query.rs | 194 ++- .../src/documents/having_proof_helpers.rs | 27 +- .../src/documents/ranked_proof_helpers.rs | 30 +- .../src/documents/sum_proof_helpers.rs | 44 +- .../document/v3/document-meta.json | 31 +- .../try_from_schema/common/mod.rs | 135 ++ .../class_methods/try_from_schema/mod.rs | 170 +++ .../class_methods/try_from_schema/v1/mod.rs | 2 + .../class_methods/try_from_schema/v3/mod.rs | 2 + .../data_contract/document_type/index/mod.rs | 595 +++++++- .../document_type/index/random_index.rs | 1 + .../document_type/index/time_range.rs | 392 +++++ .../index_level/find_first_change.rs | 36 + .../document_type/index_level/mod.rs | 101 +- .../document_type/methods/mod.rs | 35 + .../methods/versioned_methods.rs | 22 + .../methods/registration_cost/v1/mod.rs | 33 +- .../v0/mod.rs | 1 + .../data_triggers/triggers/dpns/v0/mod.rs | 2 + .../data_triggers/triggers/dpns/v1/mod.rs | 2 + .../triggers/withdrawals/v0/mod.rs | 1 + .../triggers/withdrawals/v1/mod.rs | 1 + .../batch/state/v0/fetch_documents.rs | 4 + .../batch/tests/document/dpns.rs | 9 + .../data_contract_update/mod.rs | 2 + .../src/query/document_query/v0/mod.rs | 19 +- .../query/document_query/v1/conversions.rs | 49 +- .../document_query/v1/dispatch/average.rs | 2 + .../query/document_query/v1/dispatch/count.rs | 2 + .../document_query/v1/dispatch/documents.rs | 2 + .../document_query/v1/dispatch/ranked.rs | 2 + .../query/document_query/v1/dispatch/sum.rs | 2 + .../src/query/document_query/v1/mod.rs | 96 +- .../src/query/document_query/v1/routing.rs | 17 +- .../src/query/document_query/v1/tests.rs | 581 +++++++- .../tests/vectors_documents.rs | 1 + .../benches/document_count_worst_case.rs | 3 + .../benches/document_sum_worst_case.rs | 4 + .../v0/tests/batched_group_drain.rs | 1 + .../tests/range_countable_index_e2e_tests.rs | 16 + .../v0/tests/ranked_index_e2e_tests.rs | 4 + .../v2/mod.rs | 88 +- .../drive/document/index_level_tree_types.rs | 67 +- .../validate_uniqueness_of_data/v0/mod.rs | 1 + .../validate_uniqueness_of_data/v1/mod.rs | 71 +- .../drive/document/index_uniqueness/mod.rs | 464 ++++++ .../insert/add_document_for_contract/mod.rs | 1048 +++++++++++++ .../v2/mod.rs | 123 +- .../v1/mod.rs | 438 +++++- .../rs-drive/src/drive/document/update/mod.rs | 2 + .../v0/mod.rs | 2 + .../v0/mod.rs | 1 + .../v1/mod.rs | 1 + .../drive_dispatcher.rs | 94 ++ .../query/drive_document_average_query/mod.rs | 7 + .../drive_dispatcher.rs | 8 + .../executors/per_in_value.rs | 2 + .../executors/range_no_proof.rs | 2 + .../executors/total.rs | 2 + .../drive_dispatcher.rs | 18 + .../executors/per_in_value.rs | 2 + .../executors/point_lookup_proof.rs | 3 + .../range_aggregate_carrier_proof.rs | 2 + .../executors/range_distinct_proof.rs | 2 + .../executors/range_no_proof.rs | 2 + .../executors/range_proof.rs | 3 + .../executors/total.rs | 3 + .../index_picker.rs | 32 + .../query/drive_document_count_query/tests.rs | 206 +++ .../drive_dispatcher.rs | 24 + .../index_picker.rs | 11 +- .../drive_document_ranked_query/tests.rs | 4 + .../drive_dispatcher.rs | 15 + .../executors/per_in_value.rs | 2 + .../executors/point_lookup_proof.rs | 2 + .../range_aggregate_carrier_proof.rs | 2 + .../executors/range_distinct_proof.rs | 2 + .../executors/range_no_proof.rs | 2 + .../executors/range_proof.rs | 2 + .../executors/total.rs | 2 + .../drive_document_sum_query/index_picker.rs | 32 +- .../src/query/drive_document_sum_query/mod.rs | 6 + .../drive_document_sum_query/path_query.rs | 7 + .../query/drive_document_sum_query/tests.rs | 61 +- packages/rs-drive/src/query/mod.rs | 374 ++++- .../multiple_in_path_query/v0/mod.rs | 10 + .../src/verify/document/verify_proof/mod.rs | 1 + .../verify_proof_keep_serialized/mod.rs | 1 + .../verify_start_at_document_in_proof/mod.rs | 1 + packages/rs-drive/tests/query_tests.rs | 2 + .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 14 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 20 +- .../rs-platform-version/src/version/v14.rs | 46 +- .../wallet/identity/network/contact_info.rs | 1 + .../identity/network/dpns_marketplace.rs | 5 + .../src/wallet/identity/network/profile.rs | 2 + .../dashpay/contact_request_queries.rs | 1 + .../rs-sdk/src/platform/dpns_usernames/mod.rs | 2 + .../src/platform/dpns_usernames/queries.rs | 2 + packages/rs-sdk/tests/fetch/document.rs | 1 + .../src/document/verify_proof.rs | 1 + .../document/verify_proof_keep_serialized.rs | 1 + .../verify_start_at_document_in_proof.rs | 1 + packages/wasm-sdk/src/dpns.rs | 1 + packages/wasm-sdk/src/queries/document.rs | 55 +- 120 files changed, 6915 insertions(+), 884 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/index/time_range.rs diff --git a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js index 0d1e656f487..e4b945e22d7 100644 --- a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js +++ b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js @@ -20039,6 +20039,7 @@ $root.org = (function() { * @property {number} BETWEEN_EXCLUDE_RIGHT=8 BETWEEN_EXCLUDE_RIGHT value * @property {number} IN=9 IN value * @property {number} STARTS_WITH=10 STARTS_WITH value + * @property {number} IN_TIME_RANGE=11 IN_TIME_RANGE value */ GetDocumentsRequest.WhereOperator = (function() { var valuesById = {}, values = Object.create(valuesById); @@ -20053,6 +20054,7 @@ $root.org = (function() { values[valuesById[8] = "BETWEEN_EXCLUDE_RIGHT"] = 8; values[valuesById[9] = "IN"] = 9; values[valuesById[10] = "STARTS_WITH"] = 10; + values[valuesById[11] = "IN_TIME_RANGE"] = 11; return values; })(); @@ -20870,6 +20872,7 @@ $root.org = (function() { case 8: case 9: case 10: + case 11: break; } if (message.value != null && message.hasOwnProperty("value")) { @@ -20939,6 +20942,10 @@ $root.org = (function() { case 10: message.operator = 10; break; + case "IN_TIME_RANGE": + case 11: + message.operator = 11; + break; } if (object.value != null) { if (typeof object.value !== "object") diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js index 978be7d0743..f9f35bde110 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js @@ -19531,6 +19531,7 @@ $root.org = (function() { * @property {number} BETWEEN_EXCLUDE_RIGHT=8 BETWEEN_EXCLUDE_RIGHT value * @property {number} IN=9 IN value * @property {number} STARTS_WITH=10 STARTS_WITH value + * @property {number} IN_TIME_RANGE=11 IN_TIME_RANGE value */ GetDocumentsRequest.WhereOperator = (function() { var valuesById = {}, values = Object.create(valuesById); @@ -19545,6 +19546,7 @@ $root.org = (function() { values[valuesById[8] = "BETWEEN_EXCLUDE_RIGHT"] = 8; values[valuesById[9] = "IN"] = 9; values[valuesById[10] = "STARTS_WITH"] = 10; + values[valuesById[11] = "IN_TIME_RANGE"] = 11; return values; })(); @@ -20362,6 +20364,7 @@ $root.org = (function() { case 8: case 9: case 10: + case 11: break; } if (message.value != null && message.hasOwnProperty("value")) { @@ -20431,6 +20434,10 @@ $root.org = (function() { case 10: message.operator = 10; break; + case "IN_TIME_RANGE": + case 11: + message.operator = 11; + break; } if (object.value != null) { if (typeof object.value !== "object") diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js index 10f2641e792..ad740f8e1c9 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js @@ -24389,7 +24389,8 @@ proto.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator = { BETWEEN_EXCLUDE_LEFT: 7, BETWEEN_EXCLUDE_RIGHT: 8, IN: 9, - STARTS_WITH: 10 + STARTS_WITH: 10, + IN_TIME_RANGE: 11 }; diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h index c37388a2691..96f6b2ca369 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h @@ -385,6 +385,19 @@ typedef GPB_ENUM(GetDocumentsRequest_WhereOperator) { GetDocumentsRequest_WhereOperator_BetweenExcludeRight = 8, GetDocumentsRequest_WhereOperator_In = 9, GetDocumentsRequest_WhereOperator_StartsWith = 10, + + /** + * Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the + * v0 CBOR where surface is unaffected). The clause's `field` names a + * timestamp property covered by a `timeRange` index; the operand + * (`DocumentFieldValue.text`) is the selector `"newest"` or `"oldest"`. + * The server resolves it to a concrete equality on the bucket start + * using the current block time, and the verifier re-derives the same + * bucket from the quorum-signed response metadata time — so the proof + * is an ordinary index/count proof. See `timeRange` in the document + * meta-schema and `drive::query::resolve_time_range_bucket_clause`. + **/ + GetDocumentsRequest_WhereOperator_InTimeRange = 11, }; GPBEnumDescriptor *GetDocumentsRequest_WhereOperator_EnumDescriptor(void); diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m index dd6f5cb79a7..15343d402ec 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m @@ -5155,7 +5155,7 @@ void GetDocumentsRequest_ClearVersionOneOfCase(GetDocumentsRequest *message) { "Equal\000GreaterThan\000GreaterThanOrEquals\000Le" "ssThan\000LessThanOrEquals\000Between\000BetweenE" "xcludeBounds\000BetweenExcludeLeft\000BetweenE" - "xcludeRight\000In\000StartsWith\000"; + "xcludeRight\000In\000StartsWith\000InTimeRange\000"; static const int32_t values[] = { GetDocumentsRequest_WhereOperator_Equal, GetDocumentsRequest_WhereOperator_GreaterThan, @@ -5168,6 +5168,7 @@ void GetDocumentsRequest_ClearVersionOneOfCase(GetDocumentsRequest *message) { GetDocumentsRequest_WhereOperator_BetweenExcludeRight, GetDocumentsRequest_WhereOperator_In, GetDocumentsRequest_WhereOperator_StartsWith, + GetDocumentsRequest_WhereOperator_InTimeRange, }; GPBEnumDescriptor *worker = [GPBEnumDescriptor allocDescriptorForName:GPBNSStringifySymbol(GetDocumentsRequest_WhereOperator) @@ -5196,6 +5197,7 @@ BOOL GetDocumentsRequest_WhereOperator_IsValidValue(int32_t value__) { case GetDocumentsRequest_WhereOperator_BetweenExcludeRight: case GetDocumentsRequest_WhereOperator_In: case GetDocumentsRequest_WhereOperator_StartsWith: + case GetDocumentsRequest_WhereOperator_InTimeRange: return YES; default: return NO; diff --git a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py index 004bbbe6f33..8481dc746ea 100644 --- a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py +++ b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py @@ -23,7 +23,7 @@ syntax='proto3', serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xde\x15\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xbe\x01\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xf3\x05\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xe7\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\nB\t\n\x07version\"\xe7\x15\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\xf9\x11\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1aZ\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x42\x07\n\x05value\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\x9b\x04\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x42\t\n\x07variantB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\xfb\x01\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1aU\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x99\x03\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\xef\x01\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05\x32\xce\x44\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' + serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xf1\x15\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xbe\x01\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xf3\x05\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xfa\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\n\x12\x11\n\rIN_TIME_RANGE\x10\x0b\x42\t\n\x07version\"\xe7\x15\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\xf9\x11\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1aZ\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x42\x07\n\x05value\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\x9b\x04\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x42\t\n\x07variantB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\xfb\x01\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1aU\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x99\x03\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\xef\x01\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05\x32\xce\x44\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' , dependencies=[google_dot_protobuf_dot_wrappers__pb2.DESCRIPTOR,google_dot_protobuf_dot_struct__pb2.DESCRIPTOR,google_dot_protobuf_dot_timestamp__pb2.DESCRIPTOR,]) @@ -62,8 +62,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=65780, - serialized_end=65870, + serialized_start=65799, + serialized_end=65889, ) _sym_db.RegisterEnumDescriptor(_KEYPURPOSE) @@ -307,11 +307,16 @@ serialized_options=None, type=None, create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='IN_TIME_RANGE', index=11, number=11, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, serialized_start=13436, - serialized_end=13667, + serialized_end=13686, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_WHEREOPERATOR) @@ -340,8 +345,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=28490, - serialized_end=28563, + serialized_start=28509, + serialized_end=28582, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0_RESULTTYPE) @@ -370,8 +375,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=29485, - serialized_end=29564, + serialized_start=29504, + serialized_end=29583, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_FINISHEDVOTEINFO_FINISHEDVOTEOUTCOME) @@ -400,8 +405,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=33193, - serialized_end=33254, + serialized_start=33212, + serialized_end=33273, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE_VOTECHOICETYPE) @@ -425,8 +430,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=51818, - serialized_end=51856, + serialized_start=51837, + serialized_end=51875, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSREQUEST_ACTIONSTATUS) @@ -450,8 +455,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=53103, - serialized_end=53138, + serialized_start=53122, + serialized_end=53157, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT_ACTIONTYPE) @@ -475,8 +480,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=51818, - serialized_end=51856, + serialized_start=51837, + serialized_end=51875, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSIGNERSREQUEST_ACTIONSTATUS) @@ -4155,7 +4160,7 @@ fields=[]), ], serialized_start=10896, - serialized_end=13678, + serialized_end=13697, ) @@ -4186,8 +4191,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=14121, - serialized_end=14151, + serialized_start=14140, + serialized_end=14170, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV0 = _descriptor.Descriptor( @@ -4236,8 +4241,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=13878, - serialized_end=14161, + serialized_start=13897, + serialized_end=14180, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_DOCUMENTS = _descriptor.Descriptor( @@ -4267,8 +4272,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=14121, - serialized_end=14151, + serialized_start=14140, + serialized_end=14170, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTENTRY = _descriptor.Descriptor( @@ -4317,8 +4322,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14435, - serialized_end=14511, + serialized_start=14454, + serialized_end=14530, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTENTRIES = _descriptor.Descriptor( @@ -4348,8 +4353,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=14513, - serialized_end=14627, + serialized_start=14532, + serialized_end=14646, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTRESULTS = _descriptor.Descriptor( @@ -4391,8 +4396,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14630, - serialized_end=14790, + serialized_start=14649, + serialized_end=14809, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMENTRY = _descriptor.Descriptor( @@ -4441,8 +4446,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14792, - serialized_end=14864, + serialized_start=14811, + serialized_end=14883, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMENTRIES = _descriptor.Descriptor( @@ -4472,8 +4477,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=14866, - serialized_end=14976, + serialized_start=14885, + serialized_end=14995, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMRESULTS = _descriptor.Descriptor( @@ -4515,8 +4520,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14979, - serialized_end=15133, + serialized_start=14998, + serialized_end=15152, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEENTRY = _descriptor.Descriptor( @@ -4572,8 +4577,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15135, - serialized_end=15230, + serialized_start=15154, + serialized_end=15249, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEENTRIES = _descriptor.Descriptor( @@ -4603,8 +4608,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=15232, - serialized_end=15350, + serialized_start=15251, + serialized_end=15369, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEAGGREGATE = _descriptor.Descriptor( @@ -4641,8 +4646,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=15352, - serialized_end=15406, + serialized_start=15371, + serialized_end=15425, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGERESULTS = _descriptor.Descriptor( @@ -4684,8 +4689,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15409, - serialized_end=15660, + serialized_start=15428, + serialized_end=15679, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RANKEDENTRY = _descriptor.Descriptor( @@ -4741,8 +4746,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15662, - serialized_end=15752, + serialized_start=15681, + serialized_end=15771, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RANKEDENTRIES = _descriptor.Descriptor( @@ -4784,8 +4789,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15755, - serialized_end=15909, + serialized_start=15774, + serialized_end=15928, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RESULTDATA = _descriptor.Descriptor( @@ -4848,8 +4853,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15912, - serialized_end=16451, + serialized_start=15931, + serialized_end=16470, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1 = _descriptor.Descriptor( @@ -4898,8 +4903,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14164, - serialized_end=16461, + serialized_start=14183, + serialized_end=16480, ) _GETDOCUMENTSRESPONSE = _descriptor.Descriptor( @@ -4941,8 +4946,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=13681, - serialized_end=16472, + serialized_start=13700, + serialized_end=16491, ) @@ -5015,8 +5020,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=16601, - serialized_end=16836, + serialized_start=16620, + serialized_end=16855, ) _GETDOCUMENTHISTORYREQUEST = _descriptor.Descriptor( @@ -5051,8 +5056,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16475, - serialized_end=16847, + serialized_start=16494, + serialized_end=16866, ) @@ -5090,8 +5095,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17253, - serialized_end=17308, + serialized_start=17272, + serialized_end=17327, ) _GETDOCUMENTHISTORYRESPONSE_GETDOCUMENTHISTORYRESPONSEV0_DOCUMENTHISTORY = _descriptor.Descriptor( @@ -5121,8 +5126,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17311, - serialized_end=17460, + serialized_start=17330, + serialized_end=17479, ) _GETDOCUMENTHISTORYRESPONSE_GETDOCUMENTHISTORYRESPONSEV0 = _descriptor.Descriptor( @@ -5171,8 +5176,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16979, - serialized_end=17470, + serialized_start=16998, + serialized_end=17489, ) _GETDOCUMENTHISTORYRESPONSE = _descriptor.Descriptor( @@ -5207,8 +5212,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16850, - serialized_end=17481, + serialized_start=16869, + serialized_end=17500, ) @@ -5246,8 +5251,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17633, - serialized_end=17710, + serialized_start=17652, + serialized_end=17729, ) _GETIDENTITYBYPUBLICKEYHASHREQUEST = _descriptor.Descriptor( @@ -5282,8 +5287,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17484, - serialized_end=17721, + serialized_start=17503, + serialized_end=17740, ) @@ -5333,8 +5338,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17877, - serialized_end=18059, + serialized_start=17896, + serialized_end=18078, ) _GETIDENTITYBYPUBLICKEYHASHRESPONSE = _descriptor.Descriptor( @@ -5369,8 +5374,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17724, - serialized_end=18070, + serialized_start=17743, + serialized_end=18089, ) @@ -5420,8 +5425,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18251, - serialized_end=18379, + serialized_start=18270, + serialized_end=18398, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHREQUEST = _descriptor.Descriptor( @@ -5456,8 +5461,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18073, - serialized_end=18390, + serialized_start=18092, + serialized_end=18409, ) @@ -5493,8 +5498,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19003, - serialized_end=19057, + serialized_start=19022, + serialized_end=19076, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE_GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSEV0_IDENTITYPROVEDRESPONSE = _descriptor.Descriptor( @@ -5536,8 +5541,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19060, - serialized_end=19226, + serialized_start=19079, + serialized_end=19245, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE_GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSEV0 = _descriptor.Descriptor( @@ -5586,8 +5591,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18574, - serialized_end=19236, + serialized_start=18593, + serialized_end=19255, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE = _descriptor.Descriptor( @@ -5622,8 +5627,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18393, - serialized_end=19247, + serialized_start=18412, + serialized_end=19266, ) @@ -5661,8 +5666,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=19405, - serialized_end=19490, + serialized_start=19424, + serialized_end=19509, ) _WAITFORSTATETRANSITIONRESULTREQUEST = _descriptor.Descriptor( @@ -5697,8 +5702,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19250, - serialized_end=19501, + serialized_start=19269, + serialized_end=19520, ) @@ -5748,8 +5753,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19663, - serialized_end=19902, + serialized_start=19682, + serialized_end=19921, ) _WAITFORSTATETRANSITIONRESULTRESPONSE = _descriptor.Descriptor( @@ -5784,8 +5789,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19504, - serialized_end=19913, + serialized_start=19523, + serialized_end=19932, ) @@ -5823,8 +5828,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20041, - serialized_end=20101, + serialized_start=20060, + serialized_end=20120, ) _GETCONSENSUSPARAMSREQUEST = _descriptor.Descriptor( @@ -5859,8 +5864,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19916, - serialized_end=20112, + serialized_start=19935, + serialized_end=20131, ) @@ -5905,8 +5910,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20243, - serialized_end=20323, + serialized_start=20262, + serialized_end=20342, ) _GETCONSENSUSPARAMSRESPONSE_CONSENSUSPARAMSEVIDENCE = _descriptor.Descriptor( @@ -5950,8 +5955,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20325, - serialized_end=20423, + serialized_start=20344, + serialized_end=20442, ) _GETCONSENSUSPARAMSRESPONSE_GETCONSENSUSPARAMSRESPONSEV0 = _descriptor.Descriptor( @@ -5988,8 +5993,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20426, - serialized_end=20644, + serialized_start=20445, + serialized_end=20663, ) _GETCONSENSUSPARAMSRESPONSE = _descriptor.Descriptor( @@ -6024,8 +6029,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20115, - serialized_end=20655, + serialized_start=20134, + serialized_end=20674, ) @@ -6056,8 +6061,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20819, - serialized_end=20875, + serialized_start=20838, + serialized_end=20894, ) _GETPROTOCOLVERSIONUPGRADESTATEREQUEST = _descriptor.Descriptor( @@ -6092,8 +6097,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20658, - serialized_end=20886, + serialized_start=20677, + serialized_end=20905, ) @@ -6124,8 +6129,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=21351, - serialized_end=21501, + serialized_start=21370, + serialized_end=21520, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0_VERSIONENTRY = _descriptor.Descriptor( @@ -6162,8 +6167,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=21503, - serialized_end=21561, + serialized_start=21522, + serialized_end=21580, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0 = _descriptor.Descriptor( @@ -6212,8 +6217,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21054, - serialized_end=21571, + serialized_start=21073, + serialized_end=21590, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE = _descriptor.Descriptor( @@ -6248,8 +6253,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20889, - serialized_end=21582, + serialized_start=20908, + serialized_end=21601, ) @@ -6294,8 +6299,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=21762, - serialized_end=21865, + serialized_start=21781, + serialized_end=21884, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSREQUEST = _descriptor.Descriptor( @@ -6330,8 +6335,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21585, - serialized_end=21876, + serialized_start=21604, + serialized_end=21895, ) @@ -6362,8 +6367,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22379, - serialized_end=22554, + serialized_start=22398, + serialized_end=22573, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0_VERSIONSIGNAL = _descriptor.Descriptor( @@ -6400,8 +6405,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22556, - serialized_end=22609, + serialized_start=22575, + serialized_end=22628, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -6450,8 +6455,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=22060, - serialized_end=22619, + serialized_start=22079, + serialized_end=22638, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE = _descriptor.Descriptor( @@ -6486,8 +6491,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21879, - serialized_end=22630, + serialized_start=21898, + serialized_end=22649, ) @@ -6539,8 +6544,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22743, - serialized_end=22867, + serialized_start=22762, + serialized_end=22886, ) _GETEPOCHSINFOREQUEST = _descriptor.Descriptor( @@ -6575,8 +6580,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=22633, - serialized_end=22878, + serialized_start=22652, + serialized_end=22897, ) @@ -6607,8 +6612,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=23239, - serialized_end=23356, + serialized_start=23258, + serialized_end=23375, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0_EPOCHINFO = _descriptor.Descriptor( @@ -6673,8 +6678,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=23359, - serialized_end=23525, + serialized_start=23378, + serialized_end=23544, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0 = _descriptor.Descriptor( @@ -6723,8 +6728,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=22995, - serialized_end=23535, + serialized_start=23014, + serialized_end=23554, ) _GETEPOCHSINFORESPONSE = _descriptor.Descriptor( @@ -6759,8 +6764,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=22881, - serialized_end=23546, + serialized_start=22900, + serialized_end=23565, ) @@ -6819,8 +6824,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=23687, - serialized_end=23857, + serialized_start=23706, + serialized_end=23876, ) _GETFINALIZEDEPOCHINFOSREQUEST = _descriptor.Descriptor( @@ -6855,8 +6860,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23549, - serialized_end=23868, + serialized_start=23568, + serialized_end=23887, ) @@ -6887,8 +6892,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=24294, - serialized_end=24458, + serialized_start=24313, + serialized_end=24477, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_FINALIZEDEPOCHINFO = _descriptor.Descriptor( @@ -7002,8 +7007,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=24461, - serialized_end=25004, + serialized_start=24480, + serialized_end=25023, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_BLOCKPROPOSER = _descriptor.Descriptor( @@ -7040,8 +7045,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=25006, - serialized_end=25063, + serialized_start=25025, + serialized_end=25082, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -7090,8 +7095,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=24012, - serialized_end=25073, + serialized_start=24031, + serialized_end=25092, ) _GETFINALIZEDEPOCHINFOSRESPONSE = _descriptor.Descriptor( @@ -7126,8 +7131,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23871, - serialized_end=25084, + serialized_start=23890, + serialized_end=25103, ) @@ -7165,8 +7170,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=25579, - serialized_end=25648, + serialized_start=25598, + serialized_end=25667, ) _GETCONTESTEDRESOURCESREQUEST_GETCONTESTEDRESOURCESREQUESTV0 = _descriptor.Descriptor( @@ -7262,8 +7267,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=25222, - serialized_end=25682, + serialized_start=25241, + serialized_end=25701, ) _GETCONTESTEDRESOURCESREQUEST = _descriptor.Descriptor( @@ -7298,8 +7303,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=25087, - serialized_end=25693, + serialized_start=25106, + serialized_end=25712, ) @@ -7330,8 +7335,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26135, - serialized_end=26195, + serialized_start=26154, + serialized_end=26214, ) _GETCONTESTEDRESOURCESRESPONSE_GETCONTESTEDRESOURCESRESPONSEV0 = _descriptor.Descriptor( @@ -7380,8 +7385,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=25834, - serialized_end=26205, + serialized_start=25853, + serialized_end=26224, ) _GETCONTESTEDRESOURCESRESPONSE = _descriptor.Descriptor( @@ -7416,8 +7421,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=25696, - serialized_end=26216, + serialized_start=25715, + serialized_end=26235, ) @@ -7455,8 +7460,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26729, - serialized_end=26802, + serialized_start=26748, + serialized_end=26821, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0_ENDATTIMEINFO = _descriptor.Descriptor( @@ -7493,8 +7498,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26804, - serialized_end=26871, + serialized_start=26823, + serialized_end=26890, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0 = _descriptor.Descriptor( @@ -7579,8 +7584,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26354, - serialized_end=26930, + serialized_start=26373, + serialized_end=26949, ) _GETVOTEPOLLSBYENDDATEREQUEST = _descriptor.Descriptor( @@ -7615,8 +7620,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26219, - serialized_end=26941, + serialized_start=26238, + serialized_end=26960, ) @@ -7654,8 +7659,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=27390, - serialized_end=27476, + serialized_start=27409, + serialized_end=27495, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0_SERIALIZEDVOTEPOLLSBYTIMESTAMPS = _descriptor.Descriptor( @@ -7692,8 +7697,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=27479, - serialized_end=27694, + serialized_start=27498, + serialized_end=27713, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0 = _descriptor.Descriptor( @@ -7742,8 +7747,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27082, - serialized_end=27704, + serialized_start=27101, + serialized_end=27723, ) _GETVOTEPOLLSBYENDDATERESPONSE = _descriptor.Descriptor( @@ -7778,8 +7783,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26944, - serialized_end=27715, + serialized_start=26963, + serialized_end=27734, ) @@ -7817,8 +7822,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=28404, - serialized_end=28488, + serialized_start=28423, + serialized_end=28507, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0 = _descriptor.Descriptor( @@ -7915,8 +7920,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27877, - serialized_end=28602, + serialized_start=27896, + serialized_end=28621, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST = _descriptor.Descriptor( @@ -7951,8 +7956,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27718, - serialized_end=28613, + serialized_start=27737, + serialized_end=28632, ) @@ -8024,8 +8029,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29113, - serialized_end=29587, + serialized_start=29132, + serialized_end=29606, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTESTEDRESOURCECONTENDERS = _descriptor.Descriptor( @@ -8091,8 +8096,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29590, - serialized_end=30042, + serialized_start=29609, + serialized_end=30061, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTENDER = _descriptor.Descriptor( @@ -8146,8 +8151,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30044, - serialized_end=30151, + serialized_start=30063, + serialized_end=30170, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0 = _descriptor.Descriptor( @@ -8196,8 +8201,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=28778, - serialized_end=30161, + serialized_start=28797, + serialized_end=30180, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE = _descriptor.Descriptor( @@ -8232,8 +8237,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=28616, - serialized_end=30172, + serialized_start=28635, + serialized_end=30191, ) @@ -8271,8 +8276,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=28404, - serialized_end=28488, + serialized_start=28423, + serialized_end=28507, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST_GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUESTV0 = _descriptor.Descriptor( @@ -8368,8 +8373,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30359, - serialized_end=30889, + serialized_start=30378, + serialized_end=30908, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST = _descriptor.Descriptor( @@ -8404,8 +8409,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30175, - serialized_end=30900, + serialized_start=30194, + serialized_end=30919, ) @@ -8443,8 +8448,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=31440, - serialized_end=31507, + serialized_start=31459, + serialized_end=31526, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE_GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSEV0 = _descriptor.Descriptor( @@ -8493,8 +8498,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31090, - serialized_end=31517, + serialized_start=31109, + serialized_end=31536, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE = _descriptor.Descriptor( @@ -8529,8 +8534,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30903, - serialized_end=31528, + serialized_start=30922, + serialized_end=31547, ) @@ -8568,8 +8573,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=32077, - serialized_end=32174, + serialized_start=32096, + serialized_end=32193, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST_GETCONTESTEDRESOURCEIDENTITYVOTESREQUESTV0 = _descriptor.Descriptor( @@ -8639,8 +8644,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31702, - serialized_end=32205, + serialized_start=31721, + serialized_end=32224, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST = _descriptor.Descriptor( @@ -8675,8 +8680,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31531, - serialized_end=32216, + serialized_start=31550, + serialized_end=32235, ) @@ -8714,8 +8719,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=32719, - serialized_end=32966, + serialized_start=32738, + serialized_end=32985, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE = _descriptor.Descriptor( @@ -8758,8 +8763,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32969, - serialized_end=33270, + serialized_start=32988, + serialized_end=33289, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_CONTESTEDRESOURCEIDENTITYVOTE = _descriptor.Descriptor( @@ -8810,8 +8815,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33273, - serialized_end=33550, + serialized_start=33292, + serialized_end=33569, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0 = _descriptor.Descriptor( @@ -8860,8 +8865,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32393, - serialized_end=33560, + serialized_start=32412, + serialized_end=33579, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE = _descriptor.Descriptor( @@ -8896,8 +8901,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32219, - serialized_end=33571, + serialized_start=32238, + serialized_end=33590, ) @@ -8935,8 +8940,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33735, - serialized_end=33803, + serialized_start=33754, + serialized_end=33822, ) _GETPREFUNDEDSPECIALIZEDBALANCEREQUEST = _descriptor.Descriptor( @@ -8971,8 +8976,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33574, - serialized_end=33814, + serialized_start=33593, + serialized_end=33833, ) @@ -9022,8 +9027,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33982, - serialized_end=34171, + serialized_start=34001, + serialized_end=34190, ) _GETPREFUNDEDSPECIALIZEDBALANCERESPONSE = _descriptor.Descriptor( @@ -9058,8 +9063,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33817, - serialized_end=34182, + serialized_start=33836, + serialized_end=34201, ) @@ -9090,8 +9095,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34331, - serialized_end=34382, + serialized_start=34350, + serialized_end=34401, ) _GETTOTALCREDITSINPLATFORMREQUEST = _descriptor.Descriptor( @@ -9126,8 +9131,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34185, - serialized_end=34393, + serialized_start=34204, + serialized_end=34412, ) @@ -9177,8 +9182,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34546, - serialized_end=34730, + serialized_start=34565, + serialized_end=34749, ) _GETTOTALCREDITSINPLATFORMRESPONSE = _descriptor.Descriptor( @@ -9213,8 +9218,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34396, - serialized_end=34741, + serialized_start=34415, + serialized_end=34760, ) @@ -9259,8 +9264,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34860, - serialized_end=34929, + serialized_start=34879, + serialized_end=34948, ) _GETPATHELEMENTSREQUEST = _descriptor.Descriptor( @@ -9295,8 +9300,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34744, - serialized_end=34940, + serialized_start=34763, + serialized_end=34959, ) @@ -9327,8 +9332,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35313, - serialized_end=35341, + serialized_start=35332, + serialized_end=35360, ) _GETPATHELEMENTSRESPONSE_GETPATHELEMENTSRESPONSEV0 = _descriptor.Descriptor( @@ -9377,8 +9382,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35063, - serialized_end=35351, + serialized_start=35082, + serialized_end=35370, ) _GETPATHELEMENTSRESPONSE = _descriptor.Descriptor( @@ -9413,8 +9418,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34943, - serialized_end=35362, + serialized_start=34962, + serialized_end=35381, ) @@ -9438,8 +9443,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35463, - serialized_end=35483, + serialized_start=35482, + serialized_end=35502, ) _GETSTATUSREQUEST = _descriptor.Descriptor( @@ -9474,8 +9479,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35365, - serialized_end=35494, + serialized_start=35384, + serialized_end=35513, ) @@ -9530,8 +9535,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36371, - serialized_end=36465, + serialized_start=36390, + serialized_end=36484, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_TENDERDASH = _descriptor.Descriptor( @@ -9568,8 +9573,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36698, - serialized_end=36738, + serialized_start=36717, + serialized_end=36757, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_DRIVE = _descriptor.Descriptor( @@ -9613,8 +9618,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36740, - serialized_end=36800, + serialized_start=36759, + serialized_end=36819, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL = _descriptor.Descriptor( @@ -9651,8 +9656,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36468, - serialized_end=36800, + serialized_start=36487, + serialized_end=36819, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION = _descriptor.Descriptor( @@ -9689,8 +9694,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36158, - serialized_end=36800, + serialized_start=36177, + serialized_end=36819, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_TIME = _descriptor.Descriptor( @@ -9756,8 +9761,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36802, - serialized_end=36929, + serialized_start=36821, + serialized_end=36948, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NODE = _descriptor.Descriptor( @@ -9799,8 +9804,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36931, - serialized_end=36991, + serialized_start=36950, + serialized_end=37010, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_CHAIN = _descriptor.Descriptor( @@ -9891,8 +9896,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36994, - serialized_end=37301, + serialized_start=37013, + serialized_end=37320, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NETWORK = _descriptor.Descriptor( @@ -9936,8 +9941,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37303, - serialized_end=37370, + serialized_start=37322, + serialized_end=37389, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_STATESYNC = _descriptor.Descriptor( @@ -10016,8 +10021,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37373, - serialized_end=37634, + serialized_start=37392, + serialized_end=37653, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -10082,8 +10087,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35599, - serialized_end=37634, + serialized_start=35618, + serialized_end=37653, ) _GETSTATUSRESPONSE = _descriptor.Descriptor( @@ -10118,8 +10123,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35497, - serialized_end=37645, + serialized_start=35516, + serialized_end=37664, ) @@ -10143,8 +10148,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37782, - serialized_end=37814, + serialized_start=37801, + serialized_end=37833, ) _GETCURRENTQUORUMSINFOREQUEST = _descriptor.Descriptor( @@ -10179,8 +10184,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=37648, - serialized_end=37825, + serialized_start=37667, + serialized_end=37844, ) @@ -10225,8 +10230,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37965, - serialized_end=38035, + serialized_start=37984, + serialized_end=38054, ) _GETCURRENTQUORUMSINFORESPONSE_VALIDATORSETV0 = _descriptor.Descriptor( @@ -10277,8 +10282,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=38038, - serialized_end=38213, + serialized_start=38057, + serialized_end=38232, ) _GETCURRENTQUORUMSINFORESPONSE_GETCURRENTQUORUMSINFORESPONSEV0 = _descriptor.Descriptor( @@ -10336,8 +10341,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=38216, - serialized_end=38490, + serialized_start=38235, + serialized_end=38509, ) _GETCURRENTQUORUMSINFORESPONSE = _descriptor.Descriptor( @@ -10372,8 +10377,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=37828, - serialized_end=38501, + serialized_start=37847, + serialized_end=38520, ) @@ -10418,8 +10423,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=38647, - serialized_end=38737, + serialized_start=38666, + serialized_end=38756, ) _GETIDENTITYTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -10454,8 +10459,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38504, - serialized_end=38748, + serialized_start=38523, + serialized_end=38767, ) @@ -10498,8 +10503,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39187, - serialized_end=39258, + serialized_start=39206, + serialized_end=39277, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0_TOKENBALANCES = _descriptor.Descriptor( @@ -10529,8 +10534,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39261, - serialized_end=39415, + serialized_start=39280, + serialized_end=39434, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -10579,8 +10584,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38898, - serialized_end=39425, + serialized_start=38917, + serialized_end=39444, ) _GETIDENTITYTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -10615,8 +10620,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38751, - serialized_end=39436, + serialized_start=38770, + serialized_end=39455, ) @@ -10661,8 +10666,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39588, - serialized_end=39680, + serialized_start=39607, + serialized_end=39699, ) _GETIDENTITIESTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -10697,8 +10702,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39439, - serialized_end=39691, + serialized_start=39458, + serialized_end=39710, ) @@ -10741,8 +10746,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40159, - serialized_end=40241, + serialized_start=40178, + serialized_end=40260, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0_IDENTITYTOKENBALANCES = _descriptor.Descriptor( @@ -10772,8 +10777,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40244, - serialized_end=40427, + serialized_start=40263, + serialized_end=40446, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -10822,8 +10827,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39847, - serialized_end=40437, + serialized_start=39866, + serialized_end=40456, ) _GETIDENTITIESTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -10858,8 +10863,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39694, - serialized_end=40448, + serialized_start=39713, + serialized_end=40467, ) @@ -10904,8 +10909,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40585, - serialized_end=40672, + serialized_start=40604, + serialized_end=40691, ) _GETIDENTITYTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -10940,8 +10945,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40451, - serialized_end=40683, + serialized_start=40470, + serialized_end=40702, ) @@ -10972,8 +10977,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41097, - serialized_end=41137, + serialized_start=41116, + serialized_end=41156, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -11015,8 +11020,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41140, - serialized_end=41316, + serialized_start=41159, + serialized_end=41335, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOS = _descriptor.Descriptor( @@ -11046,8 +11051,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41319, - serialized_end=41457, + serialized_start=41338, + serialized_end=41476, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -11096,8 +11101,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40824, - serialized_end=41467, + serialized_start=40843, + serialized_end=41486, ) _GETIDENTITYTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -11132,8 +11137,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40686, - serialized_end=41478, + serialized_start=40705, + serialized_end=41497, ) @@ -11178,8 +11183,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41621, - serialized_end=41710, + serialized_start=41640, + serialized_end=41729, ) _GETIDENTITIESTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -11214,8 +11219,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41481, - serialized_end=41721, + serialized_start=41500, + serialized_end=41740, ) @@ -11246,8 +11251,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41097, - serialized_end=41137, + serialized_start=41116, + serialized_end=41156, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -11289,8 +11294,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42208, - serialized_end=42391, + serialized_start=42227, + serialized_end=42410, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_IDENTITYTOKENINFOS = _descriptor.Descriptor( @@ -11320,8 +11325,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=42394, - serialized_end=42545, + serialized_start=42413, + serialized_end=42564, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -11370,8 +11375,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41868, - serialized_end=42555, + serialized_start=41887, + serialized_end=42574, ) _GETIDENTITIESTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -11406,8 +11411,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41724, - serialized_end=42566, + serialized_start=41743, + serialized_end=42585, ) @@ -11445,8 +11450,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=42688, - serialized_end=42749, + serialized_start=42707, + serialized_end=42768, ) _GETTOKENSTATUSESREQUEST = _descriptor.Descriptor( @@ -11481,8 +11486,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42569, - serialized_end=42760, + serialized_start=42588, + serialized_end=42779, ) @@ -11525,8 +11530,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43150, - serialized_end=43218, + serialized_start=43169, + serialized_end=43237, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0_TOKENSTATUSES = _descriptor.Descriptor( @@ -11556,8 +11561,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=43221, - serialized_end=43357, + serialized_start=43240, + serialized_end=43376, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0 = _descriptor.Descriptor( @@ -11606,8 +11611,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42886, - serialized_end=43367, + serialized_start=42905, + serialized_end=43386, ) _GETTOKENSTATUSESRESPONSE = _descriptor.Descriptor( @@ -11642,8 +11647,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42763, - serialized_end=43378, + serialized_start=42782, + serialized_end=43397, ) @@ -11681,8 +11686,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=43536, - serialized_end=43609, + serialized_start=43555, + serialized_end=43628, ) _GETTOKENDIRECTPURCHASEPRICESREQUEST = _descriptor.Descriptor( @@ -11717,8 +11722,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43381, - serialized_end=43620, + serialized_start=43400, + serialized_end=43639, ) @@ -11756,8 +11761,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44110, - serialized_end=44161, + serialized_start=44129, + serialized_end=44180, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -11787,8 +11792,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44164, - serialized_end=44331, + serialized_start=44183, + serialized_end=44350, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICEENTRY = _descriptor.Descriptor( @@ -11837,8 +11842,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44334, - serialized_end=44562, + serialized_start=44353, + serialized_end=44581, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICES = _descriptor.Descriptor( @@ -11868,8 +11873,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44565, - serialized_end=44765, + serialized_start=44584, + serialized_end=44784, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0 = _descriptor.Descriptor( @@ -11918,8 +11923,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43782, - serialized_end=44775, + serialized_start=43801, + serialized_end=44794, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE = _descriptor.Descriptor( @@ -11954,8 +11959,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43623, - serialized_end=44786, + serialized_start=43642, + serialized_end=44805, ) @@ -11993,8 +11998,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44920, - serialized_end=44984, + serialized_start=44939, + serialized_end=45003, ) _GETTOKENCONTRACTINFOREQUEST = _descriptor.Descriptor( @@ -12029,8 +12034,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44789, - serialized_end=44995, + serialized_start=44808, + serialized_end=45014, ) @@ -12068,8 +12073,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=45407, - serialized_end=45484, + serialized_start=45426, + serialized_end=45503, ) _GETTOKENCONTRACTINFORESPONSE_GETTOKENCONTRACTINFORESPONSEV0 = _descriptor.Descriptor( @@ -12118,8 +12123,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45133, - serialized_end=45494, + serialized_start=45152, + serialized_end=45513, ) _GETTOKENCONTRACTINFORESPONSE = _descriptor.Descriptor( @@ -12154,8 +12159,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44998, - serialized_end=45505, + serialized_start=45017, + serialized_end=45524, ) @@ -12210,8 +12215,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45938, - serialized_end=46092, + serialized_start=45957, + serialized_end=46111, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST_GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUESTV0 = _descriptor.Descriptor( @@ -12272,8 +12277,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45682, - serialized_end=46120, + serialized_start=45701, + serialized_end=46139, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST = _descriptor.Descriptor( @@ -12308,8 +12313,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45508, - serialized_end=46131, + serialized_start=45527, + serialized_end=46150, ) @@ -12347,8 +12352,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46642, - serialized_end=46704, + serialized_start=46661, + serialized_end=46723, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENTIMEDDISTRIBUTIONENTRY = _descriptor.Descriptor( @@ -12385,8 +12390,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46707, - serialized_end=46919, + serialized_start=46726, + serialized_end=46938, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENDISTRIBUTIONS = _descriptor.Descriptor( @@ -12416,8 +12421,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46922, - serialized_end=47117, + serialized_start=46941, + serialized_end=47136, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -12466,8 +12471,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46312, - serialized_end=47127, + serialized_start=46331, + serialized_end=47146, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE = _descriptor.Descriptor( @@ -12502,8 +12507,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46134, - serialized_end=47138, + serialized_start=46153, + serialized_end=47157, ) @@ -12541,8 +12546,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=47327, - serialized_end=47400, + serialized_start=47346, + serialized_end=47419, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUESTV0 = _descriptor.Descriptor( @@ -12598,8 +12603,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47403, - serialized_end=47644, + serialized_start=47422, + serialized_end=47663, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST = _descriptor.Descriptor( @@ -12634,8 +12639,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47141, - serialized_end=47655, + serialized_start=47160, + serialized_end=47674, ) @@ -12692,8 +12697,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48176, - serialized_end=48296, + serialized_start=48195, + serialized_end=48315, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSEV0 = _descriptor.Descriptor( @@ -12742,8 +12747,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47848, - serialized_end=48306, + serialized_start=47867, + serialized_end=48325, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE = _descriptor.Descriptor( @@ -12778,8 +12783,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47658, - serialized_end=48317, + serialized_start=47677, + serialized_end=48336, ) @@ -12817,8 +12822,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48448, - serialized_end=48511, + serialized_start=48467, + serialized_end=48530, ) _GETTOKENTOTALSUPPLYREQUEST = _descriptor.Descriptor( @@ -12853,8 +12858,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48320, - serialized_end=48522, + serialized_start=48339, + serialized_end=48541, ) @@ -12899,8 +12904,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48943, - serialized_end=49063, + serialized_start=48962, + serialized_end=49082, ) _GETTOKENTOTALSUPPLYRESPONSE_GETTOKENTOTALSUPPLYRESPONSEV0 = _descriptor.Descriptor( @@ -12949,8 +12954,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48657, - serialized_end=49073, + serialized_start=48676, + serialized_end=49092, ) _GETTOKENTOTALSUPPLYRESPONSE = _descriptor.Descriptor( @@ -12985,8 +12990,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48525, - serialized_end=49084, + serialized_start=48544, + serialized_end=49103, ) @@ -13031,8 +13036,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49194, - serialized_end=49286, + serialized_start=49213, + serialized_end=49305, ) _GETGROUPINFOREQUEST = _descriptor.Descriptor( @@ -13067,8 +13072,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49087, - serialized_end=49297, + serialized_start=49106, + serialized_end=49316, ) @@ -13106,8 +13111,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49655, - serialized_end=49707, + serialized_start=49674, + serialized_end=49726, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFOENTRY = _descriptor.Descriptor( @@ -13144,8 +13149,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49710, - serialized_end=49862, + serialized_start=49729, + serialized_end=49881, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFO = _descriptor.Descriptor( @@ -13180,8 +13185,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49865, - serialized_end=50003, + serialized_start=49884, + serialized_end=50022, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0 = _descriptor.Descriptor( @@ -13230,8 +13235,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49411, - serialized_end=50013, + serialized_start=49430, + serialized_end=50032, ) _GETGROUPINFORESPONSE = _descriptor.Descriptor( @@ -13266,8 +13271,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49300, - serialized_end=50024, + serialized_start=49319, + serialized_end=50043, ) @@ -13305,8 +13310,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50137, - serialized_end=50254, + serialized_start=50156, + serialized_end=50273, ) _GETGROUPINFOSREQUEST_GETGROUPINFOSREQUESTV0 = _descriptor.Descriptor( @@ -13367,8 +13372,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50257, - serialized_end=50509, + serialized_start=50276, + serialized_end=50528, ) _GETGROUPINFOSREQUEST = _descriptor.Descriptor( @@ -13403,8 +13408,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50027, - serialized_end=50520, + serialized_start=50046, + serialized_end=50539, ) @@ -13442,8 +13447,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49655, - serialized_end=49707, + serialized_start=49674, + serialized_end=49726, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPPOSITIONINFOENTRY = _descriptor.Descriptor( @@ -13487,8 +13492,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50941, - serialized_end=51136, + serialized_start=50960, + serialized_end=51155, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPINFOS = _descriptor.Descriptor( @@ -13518,8 +13523,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51139, - serialized_end=51269, + serialized_start=51158, + serialized_end=51288, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -13568,8 +13573,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50637, - serialized_end=51279, + serialized_start=50656, + serialized_end=51298, ) _GETGROUPINFOSRESPONSE = _descriptor.Descriptor( @@ -13604,8 +13609,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50523, - serialized_end=51290, + serialized_start=50542, + serialized_end=51309, ) @@ -13643,8 +13648,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51409, - serialized_end=51485, + serialized_start=51428, + serialized_end=51504, ) _GETGROUPACTIONSREQUEST_GETGROUPACTIONSREQUESTV0 = _descriptor.Descriptor( @@ -13719,8 +13724,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51488, - serialized_end=51816, + serialized_start=51507, + serialized_end=51835, ) _GETGROUPACTIONSREQUEST = _descriptor.Descriptor( @@ -13756,8 +13761,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51293, - serialized_end=51867, + serialized_start=51312, + serialized_end=51886, ) @@ -13807,8 +13812,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52249, - serialized_end=52340, + serialized_start=52268, + serialized_end=52359, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_BURNEVENT = _descriptor.Descriptor( @@ -13857,8 +13862,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52342, - serialized_end=52433, + serialized_start=52361, + serialized_end=52452, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_FREEZEEVENT = _descriptor.Descriptor( @@ -13900,8 +13905,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52435, - serialized_end=52509, + serialized_start=52454, + serialized_end=52528, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UNFREEZEEVENT = _descriptor.Descriptor( @@ -13943,8 +13948,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52511, - serialized_end=52587, + serialized_start=52530, + serialized_end=52606, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DESTROYFROZENFUNDSEVENT = _descriptor.Descriptor( @@ -13993,8 +13998,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52589, - serialized_end=52691, + serialized_start=52608, + serialized_end=52710, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_SHAREDENCRYPTEDNOTE = _descriptor.Descriptor( @@ -14038,8 +14043,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52693, - serialized_end=52793, + serialized_start=52712, + serialized_end=52812, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_PERSONALENCRYPTEDNOTE = _descriptor.Descriptor( @@ -14083,8 +14088,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52795, - serialized_end=52918, + serialized_start=52814, + serialized_end=52937, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT = _descriptor.Descriptor( @@ -14127,8 +14132,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52921, - serialized_end=53154, + serialized_start=52940, + serialized_end=53173, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENCONFIGUPDATEEVENT = _descriptor.Descriptor( @@ -14170,8 +14175,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53156, - serialized_end=53256, + serialized_start=53175, + serialized_end=53275, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICEFORQUANTITY = _descriptor.Descriptor( @@ -14208,8 +14213,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44110, - serialized_end=44161, + serialized_start=44129, + serialized_end=44180, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -14239,8 +14244,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=53548, - serialized_end=53720, + serialized_start=53567, + serialized_end=53739, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT = _descriptor.Descriptor( @@ -14294,8 +14299,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53259, - serialized_end=53745, + serialized_start=53278, + serialized_end=53764, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONEVENT = _descriptor.Descriptor( @@ -14344,8 +14349,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53748, - serialized_end=54128, + serialized_start=53767, + serialized_end=54147, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTEVENT = _descriptor.Descriptor( @@ -14380,8 +14385,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54131, - serialized_end=54270, + serialized_start=54150, + serialized_end=54289, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTCREATEEVENT = _descriptor.Descriptor( @@ -14411,8 +14416,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54272, - serialized_end=54319, + serialized_start=54291, + serialized_end=54338, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTUPDATEEVENT = _descriptor.Descriptor( @@ -14442,8 +14447,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54321, - serialized_end=54368, + serialized_start=54340, + serialized_end=54387, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTEVENT = _descriptor.Descriptor( @@ -14478,8 +14483,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54371, - serialized_end=54510, + serialized_start=54390, + serialized_end=54529, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENEVENT = _descriptor.Descriptor( @@ -14563,8 +14568,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54513, - serialized_end=55490, + serialized_start=54532, + serialized_end=55509, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONENTRY = _descriptor.Descriptor( @@ -14601,8 +14606,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55493, - serialized_end=55640, + serialized_start=55512, + serialized_end=55659, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONS = _descriptor.Descriptor( @@ -14632,8 +14637,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55643, - serialized_end=55775, + serialized_start=55662, + serialized_end=55794, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -14682,8 +14687,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51990, - serialized_end=55785, + serialized_start=52009, + serialized_end=55804, ) _GETGROUPACTIONSRESPONSE = _descriptor.Descriptor( @@ -14718,8 +14723,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51870, - serialized_end=55796, + serialized_start=51889, + serialized_end=55815, ) @@ -14778,8 +14783,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55934, - serialized_end=56140, + serialized_start=55953, + serialized_end=56159, ) _GETGROUPACTIONSIGNERSREQUEST = _descriptor.Descriptor( @@ -14815,8 +14820,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55799, - serialized_end=56191, + serialized_start=55818, + serialized_end=56210, ) @@ -14854,8 +14859,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56623, - serialized_end=56676, + serialized_start=56642, + serialized_end=56695, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0_GROUPACTIONSIGNERS = _descriptor.Descriptor( @@ -14885,8 +14890,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56679, - serialized_end=56824, + serialized_start=56698, + serialized_end=56843, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0 = _descriptor.Descriptor( @@ -14935,8 +14940,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56332, - serialized_end=56834, + serialized_start=56351, + serialized_end=56853, ) _GETGROUPACTIONSIGNERSRESPONSE = _descriptor.Descriptor( @@ -14971,8 +14976,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56194, - serialized_end=56845, + serialized_start=56213, + serialized_end=56864, ) @@ -15010,8 +15015,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56961, - serialized_end=57018, + serialized_start=56980, + serialized_end=57037, ) _GETADDRESSINFOREQUEST = _descriptor.Descriptor( @@ -15046,8 +15051,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56848, - serialized_end=57029, + serialized_start=56867, + serialized_end=57048, ) @@ -15090,8 +15095,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57032, - serialized_end=57165, + serialized_start=57051, + serialized_end=57184, ) @@ -15129,8 +15134,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57167, - serialized_end=57216, + serialized_start=57186, + serialized_end=57235, ) @@ -15161,8 +15166,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57218, - serialized_end=57313, + serialized_start=57237, + serialized_end=57332, ) @@ -15212,8 +15217,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57315, - serialized_end=57424, + serialized_start=57334, + serialized_end=57443, ) @@ -15251,8 +15256,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57426, - serialized_end=57546, + serialized_start=57445, + serialized_end=57565, ) @@ -15283,8 +15288,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57548, - serialized_end=57655, + serialized_start=57567, + serialized_end=57674, ) @@ -15334,8 +15339,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57775, - serialized_end=58000, + serialized_start=57794, + serialized_end=58019, ) _GETADDRESSINFORESPONSE = _descriptor.Descriptor( @@ -15370,8 +15375,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57658, - serialized_end=58011, + serialized_start=57677, + serialized_end=58030, ) @@ -15409,8 +15414,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58136, - serialized_end=58198, + serialized_start=58155, + serialized_end=58217, ) _GETADDRESSESINFOSREQUEST = _descriptor.Descriptor( @@ -15445,8 +15450,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58014, - serialized_end=58209, + serialized_start=58033, + serialized_end=58228, ) @@ -15496,8 +15501,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58338, - serialized_end=58570, + serialized_start=58357, + serialized_end=58589, ) _GETADDRESSESINFOSRESPONSE = _descriptor.Descriptor( @@ -15532,8 +15537,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58212, - serialized_end=58581, + serialized_start=58231, + serialized_end=58600, ) @@ -15557,8 +15562,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58721, - serialized_end=58754, + serialized_start=58740, + serialized_end=58773, ) _GETADDRESSESTRUNKSTATEREQUEST = _descriptor.Descriptor( @@ -15593,8 +15598,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58584, - serialized_end=58765, + serialized_start=58603, + serialized_end=58784, ) @@ -15632,8 +15637,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58909, - serialized_end=59055, + serialized_start=58928, + serialized_end=59074, ) _GETADDRESSESTRUNKSTATERESPONSE = _descriptor.Descriptor( @@ -15668,8 +15673,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58768, - serialized_end=59066, + serialized_start=58787, + serialized_end=59085, ) @@ -15714,8 +15719,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59209, - serialized_end=59298, + serialized_start=59228, + serialized_end=59317, ) _GETADDRESSESBRANCHSTATEREQUEST = _descriptor.Descriptor( @@ -15750,8 +15755,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59069, - serialized_end=59309, + serialized_start=59088, + serialized_end=59328, ) @@ -15782,8 +15787,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59455, - serialized_end=59510, + serialized_start=59474, + serialized_end=59529, ) _GETADDRESSESBRANCHSTATERESPONSE = _descriptor.Descriptor( @@ -15818,8 +15823,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59312, - serialized_end=59521, + serialized_start=59331, + serialized_end=59540, ) @@ -15864,8 +15869,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59685, - serialized_end=59799, + serialized_start=59704, + serialized_end=59818, ) _GETRECENTADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -15900,8 +15905,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59524, - serialized_end=59810, + serialized_start=59543, + serialized_end=59829, ) @@ -15951,8 +15956,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59978, - serialized_end=60242, + serialized_start=59997, + serialized_end=60261, ) _GETRECENTADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -15987,8 +15992,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59813, - serialized_end=60253, + serialized_start=59832, + serialized_end=60272, ) @@ -16026,8 +16031,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60255, - serialized_end=60326, + serialized_start=60274, + serialized_end=60345, ) @@ -16077,8 +16082,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60329, - serialized_end=60505, + serialized_start=60348, + serialized_end=60524, ) @@ -16109,8 +16114,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60507, - serialized_end=60599, + serialized_start=60526, + serialized_end=60618, ) @@ -16155,8 +16160,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60602, - serialized_end=60776, + serialized_start=60621, + serialized_end=60795, ) @@ -16187,8 +16192,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60779, - serialized_end=60914, + serialized_start=60798, + serialized_end=60933, ) @@ -16226,8 +16231,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=61106, - serialized_end=61203, + serialized_start=61125, + serialized_end=61222, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -16262,8 +16267,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60917, - serialized_end=61214, + serialized_start=60936, + serialized_end=61233, ) @@ -16313,8 +16318,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61410, - serialized_end=61702, + serialized_start=61429, + serialized_end=61721, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -16349,8 +16354,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61217, - serialized_end=61713, + serialized_start=61236, + serialized_end=61732, ) @@ -16395,8 +16400,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=61862, - serialized_end=61949, + serialized_start=61881, + serialized_end=61968, ) _GETSHIELDEDENCRYPTEDNOTESREQUEST = _descriptor.Descriptor( @@ -16431,8 +16436,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61716, - serialized_end=61960, + serialized_start=61735, + serialized_end=61979, ) @@ -16484,8 +16489,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62407, - serialized_end=62494, + serialized_start=62426, + serialized_end=62513, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0_ENCRYPTEDNOTES = _descriptor.Descriptor( @@ -16515,8 +16520,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62497, - serialized_end=62642, + serialized_start=62516, + serialized_end=62661, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0 = _descriptor.Descriptor( @@ -16565,8 +16570,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62113, - serialized_end=62652, + serialized_start=62132, + serialized_end=62671, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE = _descriptor.Descriptor( @@ -16601,8 +16606,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61963, - serialized_end=62663, + serialized_start=61982, + serialized_end=62682, ) @@ -16633,8 +16638,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62791, - serialized_end=62835, + serialized_start=62810, + serialized_end=62854, ) _GETSHIELDEDANCHORSREQUEST = _descriptor.Descriptor( @@ -16669,8 +16674,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62666, - serialized_end=62846, + serialized_start=62685, + serialized_end=62865, ) @@ -16701,8 +16706,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63235, - serialized_end=63261, + serialized_start=63254, + serialized_end=63280, ) _GETSHIELDEDANCHORSRESPONSE_GETSHIELDEDANCHORSRESPONSEV0 = _descriptor.Descriptor( @@ -16751,8 +16756,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62978, - serialized_end=63271, + serialized_start=62997, + serialized_end=63290, ) _GETSHIELDEDANCHORSRESPONSE = _descriptor.Descriptor( @@ -16787,8 +16792,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62849, - serialized_end=63282, + serialized_start=62868, + serialized_end=63301, ) @@ -16819,8 +16824,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63437, - serialized_end=63490, + serialized_start=63456, + serialized_end=63509, ) _GETMOSTRECENTSHIELDEDANCHORREQUEST = _descriptor.Descriptor( @@ -16855,8 +16860,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63285, - serialized_end=63501, + serialized_start=63304, + serialized_end=63520, ) @@ -16906,8 +16911,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63660, - serialized_end=63841, + serialized_start=63679, + serialized_end=63860, ) _GETMOSTRECENTSHIELDEDANCHORRESPONSE = _descriptor.Descriptor( @@ -16942,8 +16947,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63504, - serialized_end=63852, + serialized_start=63523, + serialized_end=63871, ) @@ -16974,8 +16979,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63986, - serialized_end=64032, + serialized_start=64005, + serialized_end=64051, ) _GETSHIELDEDPOOLSTATEREQUEST = _descriptor.Descriptor( @@ -17010,8 +17015,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63855, - serialized_end=64043, + serialized_start=63874, + serialized_end=64062, ) @@ -17061,8 +17066,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64181, - serialized_end=64366, + serialized_start=64200, + serialized_end=64385, ) _GETSHIELDEDPOOLSTATERESPONSE = _descriptor.Descriptor( @@ -17097,8 +17102,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64046, - serialized_end=64377, + serialized_start=64065, + serialized_end=64396, ) @@ -17129,8 +17134,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64514, - serialized_end=64561, + serialized_start=64533, + serialized_end=64580, ) _GETSHIELDEDNOTESCOUNTREQUEST = _descriptor.Descriptor( @@ -17165,8 +17170,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64380, - serialized_end=64572, + serialized_start=64399, + serialized_end=64591, ) @@ -17216,8 +17221,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64713, - serialized_end=64903, + serialized_start=64732, + serialized_end=64922, ) _GETSHIELDEDNOTESCOUNTRESPONSE = _descriptor.Descriptor( @@ -17252,8 +17257,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64575, - serialized_end=64914, + serialized_start=64594, + serialized_end=64933, ) @@ -17291,8 +17296,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=65051, - serialized_end=65118, + serialized_start=65070, + serialized_end=65137, ) _GETSHIELDEDNULLIFIERSREQUEST = _descriptor.Descriptor( @@ -17327,8 +17332,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64917, - serialized_end=65129, + serialized_start=64936, + serialized_end=65148, ) @@ -17366,8 +17371,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=65558, - serialized_end=65612, + serialized_start=65577, + serialized_end=65631, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0_NULLIFIERSTATUSES = _descriptor.Descriptor( @@ -17397,8 +17402,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=65615, - serialized_end=65757, + serialized_start=65634, + serialized_end=65776, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0 = _descriptor.Descriptor( @@ -17447,8 +17452,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65270, - serialized_end=65767, + serialized_start=65289, + serialized_end=65786, ) _GETSHIELDEDNULLIFIERSRESPONSE = _descriptor.Descriptor( @@ -17483,8 +17488,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65132, - serialized_end=65778, + serialized_start=65151, + serialized_end=65797, ) _GETIDENTITYREQUEST_GETIDENTITYREQUESTV0.containing_type = _GETIDENTITYREQUEST @@ -22475,8 +22480,8 @@ index=0, serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_start=65873, - serialized_end=74655, + serialized_start=65892, + serialized_end=74674, methods=[ _descriptor.MethodDescriptor( name='broadcastStateTransition', diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts index c394bd38fe9..f66a16306e0 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts @@ -2739,6 +2739,7 @@ export namespace GetDocumentsRequest { BETWEEN_EXCLUDE_RIGHT: 8; IN: 9; STARTS_WITH: 10; + IN_TIME_RANGE: 11; } export const WhereOperator: WhereOperatorMap; diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js index 10f2641e792..ad740f8e1c9 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js @@ -24389,7 +24389,8 @@ proto.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator = { BETWEEN_EXCLUDE_LEFT: 7, BETWEEN_EXCLUDE_RIGHT: 8, IN: 9, - STARTS_WITH: 10 + STARTS_WITH: 10, + IN_TIME_RANGE: 11 }; diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index 0ad3a197fd0..b3527de13bc 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -594,6 +594,16 @@ message GetDocumentsRequest { BETWEEN_EXCLUDE_RIGHT = 8; IN = 9; STARTS_WITH = 10; + // Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the + // v0 CBOR where surface is unaffected). The clause's `field` names a + // timestamp property covered by a `timeRange` index; the operand + // (`DocumentFieldValue.text`) is the selector `"newest"` or `"oldest"`. + // The server resolves it to a concrete equality on the bucket start + // using the current block time, and the verifier re-derives the same + // bucket from the quorum-signed response metadata time — so the proof + // is an ordinary index/count proof. See `timeRange` in the document + // meta-schema and `drive::query::resolve_time_range_bucket_clause`. + IN_TIME_RANGE = 11; } // Tagged scalar (or list) operand for a `WhereClause`. The diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index 5ab4e9bf77a..d44ab1afeb6 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -96,11 +96,43 @@ pub(super) fn assert_select_is_avg( /// that should be unreachable here (`prove = true`); reject as /// `RequestError` if they bubble through. pub(super) fn verify_average_query( - request: DocumentQuery, + mut request: DocumentQuery, response: GetDocumentsResponse, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + let contract_id = request.data_contract.id().to_buffer(); + let sum_property = request.select.field.clone(); + + // Resolve any pending time-range (`IN_TIME_RANGE`) selections into + // concrete bucket-equality clauses using the quorum-signed metadata + // block time — BEFORE mode detection and covering-index selection + // below, which read `request.where_clauses`; the prover routed on + // the resolved shape. + let resolved_time_range_fields = + super::document_query::resolve_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + + // Same provenance-vs-shape contract the server dispatchers enforce: a + // resolved field may only carry the single equality its resolution + // produced. A response accepting any other shape did not come from an + // honest prover, so reject before mode detection can route on it. + drive::query::validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &resolved_time_range_fields, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("invalid time range query shape: {}", e), + })?; + let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -110,14 +142,6 @@ pub(super) fn verify_average_query( request.document_type_name, e ), })?; - let proof = response - .proof() - .or(Err(drive_proof_verifier::Error::NoProofInResult))?; - let mtd = response - .metadata() - .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; - let contract_id = request.data_contract.id().to_buffer(); - let sum_property = request.select.field.clone(); // Resolve the SQL-shape `SumMode` the request implies — AVG // shares the routing table with SUM (see module docstring), so @@ -174,6 +198,7 @@ pub(super) fn verify_average_query( document_type.indexes(), &request.where_clauses, &sum_property, + &resolved_time_range_fields, ) .filter(|idx| idx.range_countable) .ok_or_else(|| drive_proof_verifier::Error::RequestError { @@ -189,6 +214,7 @@ pub(super) fn verify_average_query( document_type.indexes(), &request.where_clauses, &sum_property, + &resolved_time_range_fields, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| drive_proof_verifier::Error::RequestError { diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index e19e9074f53..8a942ca1fb3 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -138,11 +138,41 @@ fn limit_to_u16_or_default(limit: u32) -> Result Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + + // Resolve any pending time-range (`IN_TIME_RANGE`) selections into + // concrete bucket-equality clauses using the quorum-signed metadata + // block time — BEFORE mode detection and covering-index selection + // below, which read `request.where_clauses`; the prover routed on + // the resolved shape. + let resolved_time_range_fields = + super::document_query::resolve_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + + // Same provenance-vs-shape contract the server dispatchers enforce: a + // resolved field may only carry the single equality its resolution + // produced. A response accepting any other shape did not come from an + // honest prover, so reject before mode detection can route on it. + drive::query::validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &resolved_time_range_fields, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("invalid time range query shape: {}", e), + })?; + let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -152,12 +182,6 @@ pub(super) fn verify_count_query( request.document_type_name, e ), })?; - let proof = response - .proof() - .or(Err(drive_proof_verifier::Error::NoProofInResult))?; - let mtd = response - .metadata() - .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; // Resolve the SQL-shape `CountMode` the request implies. Same // decision tree as `validate_and_route` in the abci handler — @@ -224,6 +248,7 @@ pub(super) fn verify_count_query( DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &request.where_clauses, + &resolved_time_range_fields, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "range count requires a `range_countable: true` index whose last \ @@ -234,6 +259,7 @@ pub(super) fn verify_count_query( DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &request.where_clauses, + &resolved_time_range_fields, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove count requires a `countable: true` index whose properties \ @@ -406,3 +432,292 @@ fn single_empty_key_entry(count: u64) -> Vec { count: Some(count), }] } + +#[cfg(test)] +mod tests { + //! Offline tests for the client half of a time-range + //! (`IN_TIME_RANGE`) query: where the bucket comes from, what + //! provenance the resolution hands back, and how the count + //! surface behaves when a response cannot be verified. Nothing + //! here touches a proof — this crate builds drive with `verify` + //! only, so no Drive exists to prove against. The prove→verify + //! round trips (overlapping-window counts, a tampered signed + //! time, the documents route) live in rs-drive-abci's + //! `time_range_proof_verification`, where a populated platform + //! does. + //! + //! The property under test throughout is that the bucket is a + //! function of the **quorum-signed** metadata time and of the + //! contract's declared window, and of nothing else the client + //! could pick: a client-local clock would let a node answer with + //! whatever bucket it liked and still verify. + + use super::*; + use crate::documents::document_query::resolve_time_range_clauses_with_metadata_time; + use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{ + count_results, result_data, CountResults, ResultData, + }; + use dapi_grpc::platform::v0::get_documents_response::{ + get_documents_response_v1, GetDocumentsResponseV1, Version as ResponseVersion, + }; + use dash_context_provider::ContextProviderError; + use dpp::data_contract::{DataContractFactory, TokenConfiguration}; + use dpp::platform_value::platform_value; + use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; + use drive::query::{SelectProjection, TimeRangeSelector, WhereClause}; + use std::sync::Arc; + + const DOCUMENT_TYPE: &str = "post"; + const CREATED_AT: &str = "$createdAt"; + const BUCKETED_INDEX: &str = "trending"; + /// Six-hour windows sliding every two hours — overlap factor 3, the + /// shape a trending leaderboard actually declares. + const RANGE_SECONDS: u64 = 6 * 3_600; + const STEP_SECONDS: u64 = 2 * 3_600; + const STEP_MS: u64 = STEP_SECONDS * 1_000; + /// An exact multiple of the two-hour step, so on the `origin: 0` grid it + /// is itself a bucket start. + const BUCKET_START_MS: u64 = 1_755_000_000_000; + + fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + /// Never consulted by these tests — every one of them fails (or is + /// asserted) before a proof reaches the tenderdash binding. It exists + /// because [`verify_count_query`] takes a provider by reference. + struct UnusedProvider; + + impl ContextProvider for UnusedProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Ok([0u8; 48]) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + } + + /// A contract whose `post` doctype carries a `countable` bucketed index + /// over `(timeRange($createdAt), hashtag)`. `origin_seconds` is a + /// parameter because the pre-origin refusal is one of the behaviours + /// under test. + fn trending_contract(origin_seconds: u64) -> Arc { + let schemas = platform_value!({ + "post": { + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "indices": [ + { + "name": "trending", + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "countable": true, + "timeRange": { + "on": "$createdAt", + "range": RANGE_SECONDS, + "step": STEP_SECONDS, + "origin": origin_seconds, + }, + }, + ], + "required": ["$createdAt", "hashtag"], + "additionalProperties": false, + } + }); + let contract = DataContractFactory::new(platform_version().protocol_version) + .expect("expected a factory") + .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None) + .expect("the trending contract is well-formed") + .data_contract_owned(); + Arc::new(contract) + } + + /// The bucket start the contract's own transform puts `time_ms` in — + /// the expectation is derived from the declared window rather than + /// restated as a literal, so a fixture edit cannot leave it behind. + fn expected_bucket(contract: &DataContract, time_ms: u64) -> u64 { + contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists") + .indexes() + .get(BUCKETED_INDEX) + .expect("the bucketed index survives contract creation") + .time_range + .as_ref() + .expect("the bucketed index carries its transform") + .newest_active_start(time_ms) + .expect("the metadata time is inside an active range") + } + + fn newest_bucket_query(contract: Arc) -> DocumentQuery { + DocumentQuery::new(contract, DOCUMENT_TYPE) + .expect("the fixture has this document type") + .with_time_range(CREATED_AT, TimeRangeSelector::Newest) + } + + fn resolved_equality(request: &DocumentQuery) -> &WhereClause { + let matching: Vec<_> = request + .where_clauses + .iter() + .filter(|clause| clause.field == CREATED_AT) + .collect(); + assert_eq!( + matching.len(), + 1, + "resolution must push exactly one clause on the bucketed field" + ); + matching[0] + } + + /// The whole contract of the resolution step: the pending selector is + /// consumed, an ordinary equality on the bucketed field appears in its + /// place, and the field name comes back as provenance — which is the + /// only thing that will later keep index selection on the bucketed + /// index, since the pushed clause is indistinguishable from a + /// hand-written raw-timestamp lookup. + #[test] + fn the_newest_selector_resolves_to_the_bucket_containing_the_metadata_time() { + let contract = trending_contract(0); + let mut request = newest_bucket_query(Arc::clone(&contract)); + let metadata_time_ms = BUCKET_START_MS + 3_600_000; + + let resolved_fields = + resolve_time_range_clauses_with_metadata_time(&mut request, metadata_time_ms) + .expect("a metadata time inside an active range resolves"); + + assert_eq!(resolved_fields, vec![CREATED_AT.to_string()]); + assert!( + request.time_range_clauses.is_empty(), + "the pending selector must be drained, not left to be encoded twice" + ); + let clause = resolved_equality(&request); + assert_eq!(clause.operator, WhereOperator::Equal); + assert_eq!( + clause.value, + dpp::platform_value::Value::U64(expected_bucket(&contract, metadata_time_ms)) + ); + assert_eq!( + clause.value, + dpp::platform_value::Value::U64(BUCKET_START_MS), + "one hour past a bucket start is still inside that bucket" + ); + } + + /// The same query against a metadata time one full step later resolves + /// one bucket later — pinning that the bucket is derived from the signed + /// time rather than from anything the client holds. If the resolution + /// ever started reading a local clock this assertion is what breaks. + #[test] + fn a_metadata_time_one_step_later_resolves_to_the_next_bucket() { + let contract = trending_contract(0); + let earlier_time_ms = BUCKET_START_MS + 3_600_000; + + let mut earlier = newest_bucket_query(Arc::clone(&contract)); + resolve_time_range_clauses_with_metadata_time(&mut earlier, earlier_time_ms) + .expect("a metadata time inside an active range resolves"); + + let mut later = newest_bucket_query(Arc::clone(&contract)); + resolve_time_range_clauses_with_metadata_time(&mut later, earlier_time_ms + STEP_MS) + .expect("a metadata time inside an active range resolves"); + + let earlier_bucket = resolved_equality(&earlier) + .value + .to_integer::() + .expect("a bucket start is a millisecond timestamp"); + let later_bucket = resolved_equality(&later) + .value + .to_integer::() + .expect("a bucket start is a millisecond timestamp"); + assert_eq!( + later_bucket, + earlier_bucket + STEP_MS, + "one step of signed time must move the resolution exactly one bucket" + ); + } + + /// A metadata time before the index's origin belongs to no range at + /// all. The client refuses rather than inventing a bucket, mirroring the + /// server, which refuses the same request at resolution time — so the + /// two sides cannot disagree about whether the query was answerable. + #[test] + fn a_metadata_time_before_the_index_origin_refuses_to_resolve() { + let origin_seconds = BUCKET_START_MS / 1_000; + let contract = trending_contract(origin_seconds); + let mut request = newest_bucket_query(contract); + + let error = + resolve_time_range_clauses_with_metadata_time(&mut request, BUCKET_START_MS - 1) + .expect_err("a time predating every range has no honest bucket"); + + match error { + drive_proof_verifier::Error::RequestError { error } => assert!( + error.contains("origin"), + "expected the pre-origin refusal, got: {error}" + ), + other => panic!("expected a request error, got: {other:?}"), + } + } + + /// A node that answers a `prove = true` time-range count with data + /// instead of a proof must be rejected as an unproven response, not + /// mistaken for a query the client failed to reconstruct: the proof + /// check is the first gate in [`verify_count_query`], ahead of the + /// time-range resolution and the provenance-shape guard. Callers + /// therefore get `NoProofInResult` — "this node did not prove it" — + /// rather than a resolution error that would send them looking at their + /// own query. + #[test] + fn a_count_response_carrying_no_proof_is_rejected_as_unproven() { + let contract = trending_contract(0); + let request = newest_bucket_query(contract) + .with_select(SelectProjection::count_star()) + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: dpp::platform_value::Value::Text("ibiza".to_string()), + }); + + let response = GetDocumentsResponse { + version: Some(ResponseVersion::V1(GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Data(ResultData { + variant: Some(result_data::Variant::Counts(CountResults { + variant: Some(count_results::Variant::AggregateCount(2)), + })), + })), + metadata: Some(ResponseMetadata { + time_ms: BUCKET_START_MS + 3_600_000, + ..Default::default() + }), + })), + }; + + let error = verify_count_query(request, response, platform_version(), &UnusedProvider) + .expect_err("an unproven response must not be accepted"); + assert!( + matches!(error, drive_proof_verifier::Error::NoProofInResult), + "expected NoProofInResult, got: {error:?}" + ); + } +} diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index d7c7bf95908..e281889e94a 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -35,7 +35,7 @@ use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key; use drive::query::{ DriveDocumentQuery, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, InternalClauses, OrderClause, SelectFunction, SelectProjection, - WhereClause, WhereOperator, + TimeRangeSelector, WhereClause, WhereOperator, }; use drive_proof_verifier::{types::Documents, FromProof}; @@ -74,6 +74,14 @@ pub struct DocumentQuery { pub document_type_name: String, /// `where` clauses for the query pub where_clauses: Vec, + /// Time-range (`IN_TIME_RANGE`) selections — `(field, selector)` pairs on + /// a timestamp field covered by a `timeRange` index. These are emitted as + /// `IN_TIME_RANGE` clauses on the v1 wire and resolved server-side from + /// the current block time; the verifier re-derives the same bucket from + /// the quorum-signed response metadata time. v1-only (the v0 wire has no + /// `IN_TIME_RANGE` operator). See [`Self::with_time_range`]. + #[cfg_attr(feature = "mocks", serde(default))] + pub time_range_clauses: Vec<(String, TimeRangeSelector)>, /// SQL `GROUP BY` field names, in left-to-right order. Empty = /// no explicit grouping (aggregate count for `select=Count`). /// Only meaningful when `select=Count`; non-empty with @@ -175,6 +183,7 @@ impl DocumentQuery { data_contract: Arc::clone(&contract), document_type_name: document_type_name.to_string(), where_clauses: vec![], + time_range_clauses: vec![], group_by: Vec::new(), having: Vec::new(), order_by_clauses: vec![], @@ -209,6 +218,24 @@ impl DocumentQuery { self } + /// Restrict the query to a single time-range bucket of `field` + /// (a timestamp covered by a `timeRange` index), selecting either the + /// [`TimeRangeSelector::Newest`] or [`TimeRangeSelector::Oldest`] currently + /// active range. Emitted as an `IN_TIME_RANGE` clause on the v1 wire and + /// resolved server-side from the current block time; the proof verifier + /// re-derives the identical bucket from the quorum-signed response + /// metadata time. Requires Platform v3.1+ (v1 wire). + /// + /// Existing time-range selections are preserved. + pub fn with_time_range( + mut self, + field: impl Into, + selector: TimeRangeSelector, + ) -> Self { + self.time_range_clauses.push((field.into(), selector)); + self + } + /// Add order by clause to the query. /// /// Existing order by clauses will be preserved. @@ -444,13 +471,42 @@ impl FromProof for drive_proof_verifier::types::Documents { where Self: Sized + 'a, { - let request: Self::Request = request.into(); - let drive_query: DriveDocumentQuery = + let mut request: Self::Request = request.into(); + let response: Self::Response = response.into(); + + // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete + // bucket using the **quorum-signed** response metadata time — the same + // authoritative block time the server used to resolve it — so the + // reconstructed query matches the proof exactly. Resolve before the + // `DriveDocumentQuery` conversion so the engine sees ordinary equality + // clauses. + let mut resolved_time_range_fields = Vec::new(); + if !request.time_range_clauses.is_empty() { + // The generated `VersionedGrpcResponse::metadata()` handles both + // response envelopes (and any future one), so no hand-written + // version match is needed here. + use dapi_grpc::platform::VersionedGrpcResponse; + let time_ms = response + .metadata() + .map(|metadata| metadata.time_ms) + .map_err(|_| drive_proof_verifier::Error::ResponseDecodeError { + error: "time range query proof response is missing block-time metadata" + .to_string(), + })?; + resolved_time_range_fields = + resolve_time_range_clauses_with_metadata_time(&mut request, time_ms)?; + } + + let mut drive_query: DriveDocumentQuery = (&request) .try_into() .map_err(|e| drive_proof_verifier::Error::RequestError { error: format!("Failed to convert DocumentQuery to DriveQuery: {}", e), })?; + // The conversion cannot recover which equalities came from resolution, + // so the provenance is carried across here; index selection reads it + // to pin the query to the index that buckets the field. + drive_query.resolved_time_range_fields = resolved_time_range_fields; >::maybe_from_proof_with_metadata( drive_query, @@ -462,6 +518,57 @@ impl FromProof for drive_proof_verifier::types::Documents { } } +/// Resolve a request's pending time-range (`IN_TIME_RANGE`) selections into +/// concrete bucket-equality clauses on `request.where_clauses`, using the +/// **quorum-signed** response metadata block time — the same authoritative +/// time the server used — so the reconstructed query matches the proof +/// exactly. +/// +/// Every proof-verification path that rebuilds a drive query from a +/// [`DocumentQuery`] must call this (or perform the identical resolution) +/// *before* reading `request.where_clauses` for mode detection, covering-index +/// selection, or query reconstruction: the documents path does it inline in +/// its `FromProof` impl, and the count / sum / average aggregate helpers call +/// this before resolving their mode. Skipping it would rebuild the query from +/// a different shape than the prover used. +/// +/// Returns the resolved fields — the names whose pushed clause is a bucket +/// equality rather than a raw-timestamp one. Callers must carry them into +/// index selection (`DriveDocumentQuery::resolved_time_range_fields`, or the +/// `resolved_time_range_fields` argument of the aggregate index pickers): +/// the pushed clause is an ordinary equality and nothing downstream can +/// otherwise tell that it must be matched against bucket starts. +pub(super) fn resolve_time_range_clauses_with_metadata_time( + request: &mut DocumentQuery, + time_ms: u64, +) -> Result, drive_proof_verifier::Error> { + if request.time_range_clauses.is_empty() { + return Ok(Vec::new()); + } + let data_contract = Arc::clone(&request.data_contract); + let document_type = data_contract + .document_type_for_name(&request.document_type_name) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("document type not found for time range query: {}", e), + })?; + let time_range_clauses = std::mem::take(&mut request.time_range_clauses); + let mut resolved_fields = Vec::with_capacity(time_range_clauses.len()); + for (field, selector) in time_range_clauses { + let resolved = drive::query::resolve_time_range_bucket_clause( + &field, + selector, + document_type, + time_ms, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("failed to resolve time range clause: {}", e), + })?; + request.where_clauses.push(resolved); + resolved_fields.push(field); + } + Ok(resolved_fields) +} + /// Version-aware encoder. The dispatch is driven by the /// `drive_abci.query.document_query` feature-version on /// [`PlatformVersion`]: `0` → V0 wire (used by v3.0 testnet), `1` → @@ -483,6 +590,7 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { data_contract, document_type_name, where_clauses, + time_range_clauses, group_by, having, order_by_clauses, @@ -505,22 +613,32 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { ); match feature_version { - 0 => encode_v0( - data_contract.id().to_vec(), - document_type_name, - where_clauses, - order_by_clauses, - limit, - offset, - start, - &select, - &group_by, - &having, - ), + 0 => { + if !time_range_clauses.is_empty() { + return Err(Error::Config( + "time range (IN_TIME_RANGE) queries require Platform v3.1+ (the v1 \ + getDocuments wire); the v0 wire has no time-range operator" + .to_string(), + )); + } + encode_v0( + data_contract.id().to_vec(), + document_type_name, + where_clauses, + order_by_clauses, + limit, + offset, + start, + &select, + &group_by, + &having, + ) + } 1 => encode_v1( data_contract.id().to_vec(), document_type_name, where_clauses, + time_range_clauses, order_by_clauses, limit, offset, @@ -543,6 +661,7 @@ fn encode_v1( data_contract_id: Vec, document_type: String, where_clauses: Vec, + time_range_clauses: Vec<(String, TimeRangeSelector)>, order_by_clauses: Vec, limit: u32, offset: Option, @@ -551,10 +670,25 @@ fn encode_v1( group_by: Vec, having: Vec, ) -> Result { - let where_clauses = where_clauses + let mut where_clauses = where_clauses .into_iter() .map(where_clause_to_proto) .collect::, _>>()?; + // Append time-range selections as `IN_TIME_RANGE` clauses: field + + // `"newest"`/`"oldest"` text operand. The server resolves them to a + // concrete bucket from current block time; the verifier re-derives the + // same bucket from the signed response metadata time. + for (field, selector) in time_range_clauses { + where_clauses.push(ProtoWhereClause { + field, + operator: ProtoWhereOperator::InTimeRange as i32, + value: Some(ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Text( + selector.as_str().to_string(), + )), + }), + }); + } let order_by = order_by_clauses .into_iter() .map(order_clause_to_proto) @@ -723,13 +857,14 @@ impl<'a> From<&'a DriveDocumentQuery<'a>> for DocumentQuery { }; Self { - // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING + // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range // concept — it's a documents-only query. Default to the // v1 documents shape. select: SelectProjection::documents(), data_contract: Arc::new(data_contract), document_type_name: document_type_name.to_string(), where_clauses, + time_range_clauses: Vec::new(), group_by: Vec::new(), having: Vec::new(), order_by_clauses, @@ -759,13 +894,14 @@ impl<'a> From> for DocumentQuery { }; Self { - // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING + // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range // concept — it's a documents-only query. Default to the // v1 documents shape. select: SelectProjection::documents(), data_contract: Arc::new(data_contract), document_type_name: document_type_name.to_string(), where_clauses, + time_range_clauses: Vec::new(), group_by: Vec::new(), having: Vec::new(), order_by_clauses, @@ -780,6 +916,22 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { type Error = crate::error::Error; fn try_from(request: &'a DocumentQuery) -> Result { + // A pending (unresolved) time-range selection MUST be resolved into a + // concrete bucket-equality clause before a drive query can be built — + // see `resolve_time_range_clauses_with_metadata_time`. Silently + // dropping it here would rebuild (and verify against) a strictly + // broader query than the prover ran, so refuse instead: this makes + // "forgot to resolve" a loud error on every present and future call + // path rather than a silent verification hole. + if !request.time_range_clauses.is_empty() { + return Err(Error::Config( + "the query's time range (IN_TIME_RANGE) selections have not been resolved into \ + bucket equalities; resolve them against the response's quorum-signed metadata \ + time before building a drive query" + .to_string(), + )); + } + // let data_contract = request.data_contract.clone(); let document_type = request .data_contract @@ -872,6 +1024,12 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms: None, + // A `DocumentQuery` reaching here carries no unresolved + // time-range selection (rejected above) and cannot tell which of + // its equalities came from resolution. Callers that resolved + // selections assign the fields they resolved onto the returned + // query; everything else is a raw query. + resolved_time_range_fields: vec![], }; Ok(query) diff --git a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs index c751e30ca09..8d78adc060a 100644 --- a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs @@ -88,11 +88,30 @@ pub(super) fn assert_having_shape( /// quorum-signed app hash happens inside [`verify_having_range_proof`] /// and cannot be skipped through this helper. pub(super) fn verify_having_query( - request: DocumentQuery, + mut request: DocumentQuery, response: GetDocumentsResponse, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + + // Resolve any pending time-range selection into a where clause before + // the shape check, exactly as the server does before routing: a + // having-range query must have no where clauses, so a resolved + // selection is rejected here the same way the server rejects it — + // without this, the verifier would accept a query shape the server + // refuses. The resolved-field list is discarded: nothing survives the + // non-empty-where rejection to consume it. + super::document_query::resolve_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -102,12 +121,6 @@ pub(super) fn verify_having_query( request.document_type_name, e ), })?; - let proof = response - .proof() - .or(Err(drive_proof_verifier::Error::NoProofInResult))?; - let mtd = response - .metadata() - .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; let mode = assert_having_shape(&request, platform_version)?; diff --git a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index f442842fb4c..1ed5f58a6ee 100644 --- a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -116,11 +116,33 @@ pub(super) fn assert_ranked_shape( /// metadata before returning. There is no path through this helper /// that yields entries without that check having run. pub(super) fn verify_ranked_query( - request: DocumentQuery, + mut request: DocumentQuery, response: GetDocumentsResponse, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + + // Resolve any pending time-range (`IN_TIME_RANGE`) selections into + // concrete bucket-equality clauses before the shape check reads + // `request.where_clauses` — the same invariant the count/sum/average + // helpers follow. The ranked surface rejects where clauses today, so a + // ranked + time-range query fails the shape check below with the same + // "no where clauses" error the server's router produces (rather than + // passing the local pre-flight and dying server-side); if ranked routing + // ever grows an equality prefix, resolution is already in place. + // The resolved-field list is discarded: the ranked picker excludes + // bucketed indexes outright, so there is nothing for it to pin. + super::document_query::resolve_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -130,12 +152,6 @@ pub(super) fn verify_ranked_query( request.document_type_name, e ), })?; - let proof = response - .proof() - .or(Err(drive_proof_verifier::Error::NoProofInResult))?; - let mtd = response - .metadata() - .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; let mode = assert_ranked_shape(&request, platform_version)?; diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index fadfd00cfd8..b974d84d79c 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -89,11 +89,43 @@ pub(super) fn assert_select_is_sum( /// that should be unreachable here (`prove = true`); reject as /// `RequestError` if they bubble through. pub(super) fn verify_sum_query( - request: DocumentQuery, + mut request: DocumentQuery, response: GetDocumentsResponse, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + let contract_id = request.data_contract.id().to_buffer(); + let sum_property = request.select.field.clone(); + + // Resolve any pending time-range (`IN_TIME_RANGE`) selections into + // concrete bucket-equality clauses using the quorum-signed metadata + // block time — BEFORE mode detection and covering-index selection + // below, which read `request.where_clauses`; the prover routed on + // the resolved shape. + let resolved_time_range_fields = + super::document_query::resolve_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + + // Same provenance-vs-shape contract the server dispatchers enforce: a + // resolved field may only carry the single equality its resolution + // produced. A response accepting any other shape did not come from an + // honest prover, so reject before mode detection can route on it. + drive::query::validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &resolved_time_range_fields, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("invalid time range query shape: {}", e), + })?; + let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -103,14 +135,6 @@ pub(super) fn verify_sum_query( request.document_type_name, e ), })?; - let proof = response - .proof() - .or(Err(drive_proof_verifier::Error::NoProofInResult))?; - let mtd = response - .metadata() - .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; - let contract_id = request.data_contract.id().to_buffer(); - let sum_property = request.select.field.clone(); // Resolve the SQL-shape `SumMode` the request implies. Same // decision tree as `validate_and_route` in the abci handler — @@ -171,6 +195,7 @@ pub(super) fn verify_sum_query( document_type.indexes(), &request.where_clauses, &sum_property, + &resolved_time_range_fields, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove range SUM requires a `rangeSummable: true` index whose last \ @@ -183,6 +208,7 @@ pub(super) fn verify_sum_query( document_type.indexes(), &request.where_clauses, &sum_property, + &resolved_time_range_fields, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove SUM requires a `summable: \"\"` index whose properties \ diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 3d952b5663c..f8f2c1666d6 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties, and the requiredSince property keyword (the contract version a property is required from), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties, the requiredSince property keyword (the contract version a property is required from), and the timeRange index transform, and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { @@ -593,6 +593,35 @@ "rankedAverageable": { "type": "boolean", "description": "When true, the index's terminal property-name tree also carries an ordered secondary tree keyed by each group's average (count + sum pair) of the `averageable` property, so \"top / bottom K groups by average\" queries are O(log n + k) with proofs. Requires `rangeAverageable: true` (which itself implies `rangeCountable` + `rangeSummable`). Adds the Avg ranking axis only — it does NOT imply `rankedCountable` or `rankedSummable`; each ranking axis costs its own secondary tree and is opted into separately." + }, + "timeRange": { + "type": "object", + "properties": { + "on": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Name of the timestamp index property to bucket. Must be this index's first property and name one of the system timestamps ($createdAt, $updatedAt or $transferredAt). A timeRange index may be unique only when range equals step (non-overlapping windows) and `on` is $createdAt." + }, + "range": { + "type": "integer", + "minimum": 1, + "description": "Length of each time range window, in seconds. Must be an exact multiple of `step`." + }, + "step": { + "type": "integer", + "minimum": 1, + "description": "Interval between successive range starts, in seconds. When `range` > `step` the ranges overlap and a document is indexed under `range / step` bucket-start values, bounded by a protocol-versioned cap (24 at protocol version 14)." + }, + "origin": { + "type": "integer", + "minimum": 0, + "description": "Reference origin for range alignment, in seconds. Range starts are `origin + k * step`. Defaults to 0." + } + }, + "required": ["on", "range", "step"], + "additionalProperties": false, + "description": "Buckets the first index property's timestamp into fixed-length, regularly-spaced (possibly overlapping) time ranges. The window parameters (`range`, `step`, `origin`) are declared in seconds, since a bucket is selected from block time and the target block interval is five seconds; the stored key is the range start as a u64 millisecond timestamp, so it stays directly comparable to the source timestamp it buckets. Enables trending/leaderboard queries within the newest/oldest active range. A system-timestamp source must be listed in the document type's required fields, and documents whose timestamp predates `origin` belong to no range (they are absent from this index). Available from protocol version 14." } }, "required": [ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index ea612aa9ff4..067340dccf1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -22,6 +22,8 @@ use crate::data_contract::config::v0::DataContractConfigGettersV0; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::consensus_or_protocol_value_error; use crate::data_contract::document_type::index::Index; +#[cfg(feature = "validation")] +use crate::data_contract::document_type::index::TimeRangeTransform; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::document_type::property::DocumentPropertyType; @@ -192,6 +194,13 @@ pub(super) struct ParserGeneration { pub ranked_index_key_length_check: RankedIndexKeyLengthCheck, /// See [`RankedIndexStructureCheck`]. pub ranked_index_structure_check: RankedIndexStructureCheck, + + // ---- TIME RANGE: the other generation-3 addition ---- + /// Whether the index grammar admits the `timeRange` keyword. Forwarded to + /// [`Index::try_from_value_map`] exactly like `admit_ranked`: when `false` + /// the key falls through to the unknown-key arm and is rejected as any + /// pre-generation-3 node rejected it. + pub admit_time_range: bool, } /// Reject a document type whose name is not a non-empty ASCII @@ -487,6 +496,7 @@ pub(super) fn parse_document_type_core( schema_map, &flags, &properties.flattened_document_properties, + &properties.required_fields, validation_operations, )?; @@ -778,6 +788,7 @@ fn parse_indices( schema_map: &[(Value, Value)], flags: &DocumentTypeFlags, flattened_document_properties: &IndexMap, + required_fields: &BTreeSet, validation_operations: &mut impl Extend, ) -> Result<(BTreeMap, IndexLevel), ProtocolError> { // Initialize indices @@ -816,6 +827,7 @@ fn parse_indices( .map_err(consensus_or_protocol_value_error)? .as_slice(), ctx.generation.admit_ranked, + ctx.generation.admit_time_range, ) .map_err(consensus_or_protocol_data_contract_error)?; @@ -846,6 +858,95 @@ fn parse_indices( ))); } + // TIME RANGE: the source must be a millisecond + // timestamp — a system timestamp ($createdAt / + // $updatedAt / $transferredAt) or a user `Date` + // property. Structural checks (first-property, + // range % step, the uniqueness rules — unique only + // over non-overlapping windows on `$createdAt` — + // and non-contested) already happened in `Index` + // parsing; the checks here need the document schema + // or the platform version, so they live here. A + // generation without the `timeRange` grammar never + // parses a transform, so this is a no-op there. + if let Some(transform) = &index.time_range { + // The overlap factor is the number of index + // entries a single document produces on this + // index — its write amplification — so its cap + // is a versioned system limit rather than a + // structural constant: retuning it is a + // protocol-version decision, not a code edit. + // `None` means a protocol version predating + // time-range indexes, which cannot reach here + // because the keyword does not parse there. + if let Some(max_overlap_factor) = ctx + .platform_version + .system_limits + .max_time_range_overlap_factor + { + let overlap = transform.overlap_factor(); + if overlap > max_overlap_factor { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "timeRange overlap factor (range / step = {}) \ + exceeds the maximum of {}; a smaller window or a \ + larger step is required to bound per-document \ + index entries", + overlap, max_overlap_factor + )), + )); + } + } + let source = transform.source.as_str(); + let is_system_timestamp = matches!( + source, + property_names::CREATED_AT + | property_names::UPDATED_AT + | property_names::TRANSFERRED_AT + ); + // A system timestamp is only ever populated when + // the schema *requires* it. Without this check a + // contract could declare `timeRange.on: + // "$createdAt"` on a doctype that never sets + // $createdAt: every document would take the null + // branch, the index would hold nothing but null + // entries, and — the transform being immutable — + // the owner could never fix it. + if is_system_timestamp && !required_fields.contains(source) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "timeRange.on (\"{}\") names a system timestamp the \ + document type does not require; add it to the \ + document type's required fields so documents actually \ + carry it", + source + )), + )); + } + // Only the system timestamps can be a source. A + // user property cannot: the document-schema + // grammar has no type that parses to + // `DocumentPropertyType::Date` (`type: "string"` + // with `format: "date-time"` stays `String`, and + // the meta-schema's `type` enum has no `"date"`), + // so accepting `Date`-typed user properties here + // would be a dead branch advertising a source no + // valid contract can declare. Lift this together + // with a reachable millisecond-timestamp property + // representation, not before. + if !is_system_timestamp { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "timeRange.on (\"{}\") must name one of the system \ + timestamps ($createdAt, $updatedAt or $transferredAt); \ + user-defined properties are not supported as a \ + time-range source", + source + )), + )); + } + } + validation_operations.extend(std::iter::once( ProtocolValidationOperation::DocumentTypeSchemaIndexValidation( index.properties.len() as u64, @@ -977,6 +1078,40 @@ fn parse_indices( // core never branches on a version. (ctx.generation.ranked_index_structure_check)(&indices)?; + // TIME RANGE: all indices that share a first property must agree on its + // time-range transform: either every such index buckets it with the + // identical transform, or none do. Otherwise the merged index trie node + // for that first property would be ambiguous (bucketed for one index, + // plain for another), so we reject the contract up front. No-op for + // generations whose grammar has no `timeRange`. + #[cfg(feature = "validation")] + if ctx.full_validation { + let mut first_property_time_range: BTreeMap<&str, Option<&TimeRangeTransform>> = + BTreeMap::new(); + for index in indices.values() { + let Some(first) = index.properties.first() else { + continue; + }; + let transform = index.time_range.as_ref(); + match first_property_time_range.get(first.name.as_str()) { + Some(existing) if *existing != transform => { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "indices that share the first property \"{}\" must agree on its \ + timeRange transform: either all bucket it identically or none \ + do", + first.name + )), + )); + } + Some(_) => {} + None => { + first_property_time_range.insert(first.name.as_str(), transform); + } + } + } + } + let index_structure = IndexLevel::try_from_indices(indices.values(), ctx.name, ctx.platform_version)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 41e5da7c184..83f4866c288 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -442,6 +442,33 @@ mod tests { try_document_type_from_schema_on_version(schema, PlatformVersion::latest()) } + /// Same as [`try_document_type_from_schema`] but with `full_validation` + /// on — the index validations (the timeRange source rules among them) + /// only run on the validating parse. + fn try_document_type_from_schema_full_validation( + schema: serde_json::Value, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + + let value = platform_value::to_value(schema).expect("schema should convert"); + + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + } + fn try_document_type_from_schema_on_version( schema: serde_json::Value, platform_version: &PlatformVersion, @@ -825,6 +852,7 @@ mod tests { .expect("a parse predating refersTo should ignore the keyword entirely"); } + // ================================================================ // requiredSince // ================================================================ @@ -1017,4 +1045,146 @@ mod tests { assert_eq!(properties.get("a").unwrap().required_since, None); assert!(properties.get("a").unwrap().required); } + #[test] + fn should_reject_time_range_on_user_defined_property() { + // No user property type parses to a millisecond timestamp — `type: + // "string"` with `format: "date-time"` stays `String` — so a + // user-defined time-range source must be rejected rather than + // accepted as an index that could never bucket anything meaningful. + let err = try_document_type_from_schema_full_validation(json!({ + "type": "object", + "properties": { + "eventAt": { + "type": "string", + "maxLength": 63, + "position": 0 + } + }, + "indices": [ + { + "name": "byEventTime", + "properties": [{ "eventAt": "asc" }], + "timeRange": { "on": "eventAt", "range": 21_600u64, "step": 7_200u64 } + } + ], + "required": ["eventAt"], + "additionalProperties": false + })) + .expect_err("a user-defined time-range source must be rejected"); + + assert!( + err.to_string().contains("system timestamps"), + "expected the system-timestamp restriction, got: {err}" + ); + } + + #[test] + fn should_parse_time_range_on_required_system_timestamp() { + try_document_type_from_schema_full_validation(json!({ + "type": "object", + "properties": { + "hashtag": { + "type": "string", + "maxLength": 63, + "position": 0 + } + }, + "indices": [ + { + "name": "trending", + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "timeRange": { "on": "$createdAt", "range": 21_600u64, "step": 7_200u64 } + } + ], + "required": ["$createdAt", "hashtag"], + "additionalProperties": false + })) + .expect("a required system timestamp is the supported time-range source"); + } + + /// The overlap-factor cap is a versioned system limit + /// (`SystemLimits::max_time_range_overlap_factor`), enforced at + /// registration rather than at parse; both sides of the boundary are + /// pinned here through the versioned dispatch. 24 is a day-long window + /// sliding hourly — the natural worst case the cap is sized for. + #[test] + fn should_enforce_the_versioned_time_range_overlap_factor_cap() { + let time_range_schema = |range_seconds: u64| { + json!({ + "type": "object", + "properties": { + "hashtag": { + "type": "string", + "maxLength": 63, + "position": 0 + } + }, + "indices": [ + { + "name": "trending", + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "timeRange": { "on": "$createdAt", "range": range_seconds, "step": 3_600u64 } + } + ], + "required": ["$createdAt", "hashtag"], + "additionalProperties": false + }) + }; + + try_document_type_from_schema_full_validation(time_range_schema(24 * 3_600)) + .expect("an overlap factor at the cap must register"); + + let err = try_document_type_from_schema_full_validation(time_range_schema(25 * 3_600)) + .expect_err("an overlap factor over the cap must be rejected at registration"); + assert!( + err.to_string().contains("overlap factor"), + "expected the overlap-factor rejection, got: {err}" + ); + } + + #[test] + fn should_parse_unique_time_range_index_with_non_overlapping_windows_on_created_at() { + // "one report per author per day": `range == step` makes the buckets a + // partition, and `$createdAt` is immutable, which is exactly the pair + // of conditions a unique bucketed index needs. Asserted through the + // full-validation parse so the doctype-level checks (unique-index + // limit, required system timestamp) run too. + const ONE_DAY_SECONDS: u64 = 24 * 3_600; + let document_type = try_document_type_from_schema_full_validation(json!({ + "type": "object", + "properties": { + "author": { + "type": "string", + "maxLength": 63, + "position": 0 + } + }, + "indices": [ + { + "name": "dailyReport", + "properties": [{ "$createdAt": "asc" }, { "author": "asc" }], + "unique": true, + "timeRange": { "on": "$createdAt", "range": ONE_DAY_SECONDS, "step": ONE_DAY_SECONDS } + } + ], + "required": ["$createdAt", "author"], + "additionalProperties": false + })) + .expect("a non-overlapping $createdAt bucketing may be unique"); + + let index = document_type + .as_ref() + .indexes() + .get("dailyReport") + .expect("the index should be registered") + .clone(); + assert!(index.unique); + assert_eq!( + index + .time_range + .expect("the transform should survive the schema parse") + .overlap_factor(), + 1 + ); + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs index 86107721e1f..f322c35c812 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs @@ -100,6 +100,8 @@ impl DocumentTypeV1 { admit_ranked: false, ranked_index_key_length_check: common::no_ranked_index_key_length_check, ranked_index_structure_check: common::no_ranked_index_structure_check, + // TIME RANGE: also a generation-3 keyword; not in this grammar. + admit_time_range: false, }, platform_version, ) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 1f449468155..f51e9e55087 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -259,6 +259,8 @@ fn try_from_schema_generation_3( admit_ranked: true, ranked_index_key_length_check: RANKED_INDEX_KEY_LENGTH_CHECK, ranked_index_structure_check: validate_no_ranked_prefix_overlap, + // TIME RANGE: the other keyword generation 3 adds. + admit_time_range: true, }, platform_version, )?; diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 2f099c93014..719b73506d3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -15,6 +15,7 @@ use crate::data_contract::errors::DataContractError; use crate::ProtocolError; use anyhow::anyhow; +use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::ContestedIndexResolution::MasternodeVote; #[cfg(feature = "validation")] use crate::data_contract::errors::DataContractError::RegexError; @@ -25,6 +26,9 @@ use std::sync::OnceLock; use std::{collections::BTreeMap, convert::TryFrom}; pub mod random_index; +pub mod time_range; + +pub use time_range::TimeRangeTransform; /// Index-level keyword opting the index's terminal property-name tree into the /// **Count** ranking axis: an ordered secondary tree keyed by each group's @@ -46,6 +50,12 @@ pub const RANKED_SUMMABLE: &str = "rankedSummable"; /// costs its own secondary tree, so `rankedAverageable` adds the Avg axis and /// nothing else. pub const RANKED_AVERAGEABLE: &str = "rankedAverageable"; +/// Index-level keyword bucketing the index's first property (a millisecond +/// timestamp) into fixed-length, regularly-spaced, possibly overlapping time +/// ranges whose window is declared in seconds; a document is indexed under +/// every range containing its timestamp. See [`TimeRangeTransform`]. +/// Meta-schema v3+ (protocol version 14). +pub const TIME_RANGE: &str = "timeRange"; #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)] @@ -476,6 +486,20 @@ pub struct Index { /// `book/src/drive/ranked-index-examples.md` for the worked example. #[cfg_attr(feature = "serde-conversion", serde(default))] pub ranked_averageable: bool, + /// When set, the index's first property is a timestamp that is bucketed + /// into fixed-length, regularly-spaced (possibly overlapping) time + /// ranges. The stored key for that property is the range *start* (a + /// `u64` millisecond timestamp), and a single document is indexed under + /// every range whose window contains its timestamp. See + /// [`TimeRangeTransform`]. The named source must be this index's first + /// property. Part of the meta-schema-v3 grammar (protocol version 14 and + /// later). + // + // `serde(default)`: same reasoning as the ranked axes above — the key was + // added after the struct's serde shape was in the wild, so pre-existing + // JSON must still deserialize. + #[cfg_attr(feature = "serde-conversion", serde(default))] + pub time_range: Option, } impl Index { @@ -644,7 +668,7 @@ impl TryFrom<&[(Value, Value)]> for Index { /// `document_type_schema` version must go through /// [`Index::try_from_value_map`] instead so PV14+ contracts can use them. fn try_from(index_type_value_map: &[(Value, Value)]) -> Result { - Index::try_from_value_map(index_type_value_map, false) + Index::try_from_value_map(index_type_value_map, false, false) } } @@ -663,9 +687,16 @@ impl Index { /// The meta-schema is the other half of this gate — v2 rejects the keys via /// `additionalProperties: false` — but it only runs under /// `full_validation`, which is why the grammar itself is version-gated too. + /// + /// `time_range_allowed` gates the `timeRange` keyword exactly the same + /// way: it also joined the grammar at meta-schema v3 (protocol version + /// 14). The two flags are separate parameters because they are separate + /// grammar admissions — a future generation may admit one without the + /// other. pub fn try_from_value_map( index_type_value_map: &[(Value, Value)], ranked_aggregates_allowed: bool, + time_range_allowed: bool, ) -> Result { // Decouple the map // It contains properties and a unique key @@ -719,6 +750,7 @@ impl Index { let mut ranked_countable = false; let mut ranked_summable = false; let mut ranked_averageable = false; + let mut time_range: Option = None; for (key_value, value_value) in index_type_value_map { let key = key_value.to_str()?; @@ -974,6 +1006,90 @@ impl Index { "rankedAverageable value must be a boolean".to_string(), ))?; } + // `timeRange` is guarded the same way as the ranking keywords + // above: it joined the grammar at meta-schema v3, so below + // that the key falls through to the unknown-property arm. + TIME_RANGE if time_range_allowed => { + let time_range_map = + value_value + .as_map() + .ok_or(DataContractError::ValueWrongType( + "timeRange value should be a map".to_string(), + ))?; + + let mut source: Option = None; + let mut range_seconds: Option = None; + let mut step_seconds: Option = None; + let mut origin_seconds: u64 = 0; + + for (tr_key_value, tr_value) in time_range_map { + let tr_key = tr_key_value + .to_str() + .map_err(|e| DataContractError::ValueDecodingError(e.to_string()))?; + match tr_key { + "on" => { + source = Some( + tr_value + .as_text() + .ok_or(DataContractError::ValueWrongType( + "timeRange.on should be a string".to_string(), + ))? + .to_owned(), + ); + } + "range" => { + range_seconds = Some(tr_value.to_integer().map_err(|_| { + DataContractError::ValueWrongType( + "timeRange.range should be an integer".to_string(), + ) + })?); + } + "step" => { + step_seconds = Some(tr_value.to_integer().map_err(|_| { + DataContractError::ValueWrongType( + "timeRange.step should be an integer".to_string(), + ) + })?); + } + "origin" => { + origin_seconds = tr_value.to_integer().map_err(|_| { + DataContractError::ValueWrongType( + "timeRange.origin should be an integer".to_string(), + ) + })?; + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "unexpected timeRange field: {}", + other + ))); + } + } + } + + let source = source.ok_or(DataContractError::InvalidContractStructure( + "timeRange requires an `on` field naming the source timestamp property" + .to_string(), + ))?; + let range_seconds = + range_seconds.ok_or(DataContractError::InvalidContractStructure( + "timeRange requires a `range` field (range length in seconds)" + .to_string(), + ))?; + let step_seconds = + step_seconds.ok_or(DataContractError::InvalidContractStructure( + "timeRange requires a `step` field (interval between range starts in \ + seconds)" + .to_string(), + ))?; + + time_range = Some(TimeRangeTransform { + source, + range_seconds, + step_seconds, + origin_seconds, + }); + } "properties" => { let properties = value_value @@ -1221,6 +1337,156 @@ impl Index { )); } + // A time-range transform buckets the index's *first* property. Validate + // the structural constraints that don't need document-type context here + // (the source must be a timestamp/Date field — which does need the + // schema — is checked in `try_from_schema`). Uniqueness is admitted + // only for the narrow shape where it has a meaning: non-overlapping + // windows over an immutable timestamp. + if let Some(transform) = &time_range { + // Uniqueness and bucketing only compose when the windows + // *partition* time. With `range == step` (overlap factor 1) every + // document lands in exactly one bucket, so "at most one document + // per window per remaining key tuple" is a coherent constraint — + // one report per author per day. With `range > step` a single + // document is indexed under `range / step` bucket keys at once, so + // it would occupy the unique slot of several windows while two + // documents with different timestamps would collide in the windows + // they happen to share: there is no constraint left to enforce. + // + // Compared before the zero-step / multiple checks below because it + // only reads the declared numbers; a malformed transform still + // gets its own, more specific rejection there. + if unique && transform.range_seconds != transform.step_seconds { + return Err(DataContractError::InvalidContractStructure( + "a timeRange index cannot be unique unless range equals step \ + (non-overlapping windows): with overlapping ranges one document is indexed \ + under several bucket keys at once, which uniqueness cannot express" + .to_string(), + )); + } + // Non-overlapping windows are still only safe over an *immutable* + // source. Uniqueness validation probes the bucket the candidate + // document's timestamp falls into and leans on `allow_original`: + // a document may keep occupying its own slot unless one of the + // index's values changed, in which case the tuple moved and the + // new one must be free. `$createdAt` never changes across a + // revision, so the bucket component is fixed and the tuple can + // only move through the index's other properties — exactly the + // changes the uniqueness request reports. `$updatedAt` / + // `$transferredAt` change on *every* revision, silently migrating + // the document to a new bucket; validating that would need the old + // bucket alongside the new one to know which slot is being + // vacated, and the uniqueness request carries only the new + // timestamps. Rejected here rather than half-checked; liftable + // once the request plumbs the previous timestamp through. + if unique && transform.source != property_names::CREATED_AT { + return Err(DataContractError::InvalidContractStructure(format!( + "a unique timeRange index requires \"{}\" as its source, not \"{}\": \ + \"{}\" is immutable across updates, so a document's bucket is fixed and \ + the uniqueness tuple only moves when the index's other properties change. \ + A mutable timestamp source would move the document to a new bucket on \ + every revision, which uniqueness validation cannot check without tracking \ + the old bucket", + property_names::CREATED_AT, + transform.source, + property_names::CREATED_AT + ))); + } + if contested_index.is_some() { + return Err(DataContractError::InvalidContractStructure( + "a timeRange index cannot be a contested resource".to_string(), + )); + } + // The ranked query surface excludes bucketed indexes — a document + // is stored once per containing bucket, so ranking groups keyed by + // bucket starts would score each document `overlap_factor` times. + // Since no ranked query can ever select such an index, allowing + // the flags would only make the contract pay for ranked + // secondaries that are unreachable; reject the combination until + // bucket-aware ranked semantics are deliberately designed. + if ranked_countable || ranked_summable || ranked_averageable { + return Err(DataContractError::InvalidContractStructure( + "a timeRange index cannot be ranked (rankedCountable / rankedSummable / \ + rankedAverageable): ranked queries have no time-bucket semantics, so the \ + ranked secondaries would be maintained but never servable" + .to_string(), + )); + } + // Same reasoning as the ranked `nullSearchable` rejection above, + // plus a write-path invariant: the insert, delete and update + // walkers all agree that a null timestamp keeps a single ordinary + // null entry with its real reference. `nullSearchable: false` + // would suppress that reference on insert while the set-diff + // update path maintains it, so the combination is rejected. + if !null_searchable { + return Err(DataContractError::InvalidContractStructure( + "a timeRange index is not supported with nullSearchable: false: documents \ + with a null timestamp keep a single ordinary null entry; leave \ + nullSearchable at its default (true)" + .to_string(), + )); + } + // The window is declared in seconds but every quantity it is + // measured against — the source timestamps, the bucket starts, the + // stored index keys — is a millisecond timestamp, so a parameter is + // only usable if scaling it by 1_000 still fits in a `u64`. + // Rejecting the unscalable ones here is what lets the transform's + // `*_ms` accessors saturate instead of returning a `Result` no + // validated contract could ever trip. + for (field, seconds) in [ + ("range", transform.range_seconds), + ("step", transform.step_seconds), + ("origin", transform.origin_seconds), + ] { + if seconds > u64::MAX / 1_000 { + return Err(DataContractError::InvalidContractStructure(format!( + "timeRange.{} ({} seconds) is too large to express in milliseconds", + field, seconds + ))); + } + } + if transform.step_seconds == 0 { + return Err(DataContractError::InvalidContractStructure( + "timeRange.step must be greater than zero".to_string(), + )); + } + if transform.range_seconds == 0 { + return Err(DataContractError::InvalidContractStructure( + "timeRange.range must be greater than zero".to_string(), + )); + } + if transform.range_seconds % transform.step_seconds != 0 { + return Err(DataContractError::InvalidContractStructure( + "timeRange.range must be an exact multiple of timeRange.step so the number \ + of overlapping ranges per document is deterministic" + .to_string(), + )); + } + // The overlap-factor cap is NOT checked here: it is a versioned + // system limit (`SystemLimits::max_time_range_overlap_factor`), + // and this parser has no platform version. It is enforced at + // contract registration in `try_from_schema`, like the other + // versioned index limits; this function keeps only the structural + // rules that hold at every version. + match index_properties.first() { + Some(first) if first.name == transform.source => {} + Some(first) => { + return Err(DataContractError::InvalidContractStructure(format!( + "timeRange.on (\"{}\") must name the first index property (\"{}\"); a \ + time range partitions the index by time, so it has to be the leading \ + property", + transform.source, first.name + ))); + } + None => { + return Err(DataContractError::InvalidContractStructure( + "an index with a timeRange must have at least one property".to_string(), + )); + } + } + } + // If the index didn't have a name, derive one deterministically from // its properties and their directions. Every document meta-schema // (v0/v1/v2) requires `name`, so an unnamed index can only reach this @@ -1265,6 +1531,7 @@ impl Index { ranked_countable, ranked_summable, ranked_averageable, + time_range, }) } } @@ -1335,9 +1602,292 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, } } + // ----------------------------------------------------------------------- + // timeRange parsing + structural validation tests + // ----------------------------------------------------------------------- + + /// `time_range` is `(on, range, step)` with the window parameters in the + /// seconds the contract grammar declares them in. + fn index_value_map( + first_property: &str, + time_range: Option<(&str, u64, u64)>, + ) -> Vec<(Value, Value)> { + let property = Value::Map(vec![( + Value::Text(first_property.to_string()), + Value::Text("asc".to_string()), + )]); + let hashtag = Value::Map(vec![( + Value::Text("hashtag".to_string()), + Value::Text("asc".to_string()), + )]); + + let mut map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("trending".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![property, hashtag]), + ), + ]; + + if let Some((on, range, step)) = time_range { + map.push(( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + (Value::Text("on".to_string()), Value::Text(on.to_string())), + (Value::Text("range".to_string()), Value::U64(range)), + (Value::Text("step".to_string()), Value::U64(step)), + ]), + )); + } + + map + } + + #[test] + fn time_range_index_parses() { + // A six-hour window refreshed every two hours. + let map = index_value_map("$createdAt", Some(("$createdAt", 21_600, 7_200))); + let index = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + let transform = index.time_range.expect("time_range should be set"); + assert_eq!(transform.source, "$createdAt"); + assert_eq!(transform.range_seconds, 21_600); + assert_eq!(transform.step_seconds, 7_200); + assert_eq!(transform.origin_seconds, 0); + assert_eq!(transform.overlap_factor(), 3); + // The parsed seconds are what the bucket math scales into the + // millisecond domain the source timestamps live in. + assert_eq!(transform.range_ms(), 21_600_000); + assert_eq!(transform.step_ms(), 7_200_000); + } + + #[test] + fn time_range_rejects_non_multiple_range() { + let map = index_value_map("$createdAt", Some(("$createdAt", 21_600, 7_000))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + #[test] + fn time_range_rejects_parameters_that_cannot_be_expressed_in_milliseconds() { + // `range == step` keeps the overlap factor at 1 and the multiple check + // satisfied, so the millisecond-expressibility guard is the only rule + // that can reject this transform. + let too_large = u64::MAX / 1_000 + 1; + let map = index_value_map("$createdAt", Some(("$createdAt", too_large, too_large))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + match err { + DataContractError::InvalidContractStructure(message) => { + assert!( + message.contains("too large to express in milliseconds"), + "expected the millisecond-expressibility rejection, got: {message}" + ); + } + other => panic!("expected InvalidContractStructure, got: {other:?}"), + } + } + + #[test] + fn time_range_parse_accepts_any_overlap_factor() { + // The overlap-factor cap is a versioned system limit enforced at + // contract registration (`try_from_schema`), not a structural parse + // rule — this parser has no platform version to read it from. A + // huge factor must therefore parse; registration is what rejects it. + let map = index_value_map("$createdAt", Some(("$createdAt", 300, 1))); + let index = Index::try_from_value_map(map.as_slice(), false, true) + .expect("the parser applies structural rules only"); + assert_eq!( + index + .time_range + .expect("time_range should be set") + .overlap_factor(), + 300 + ); + } + + #[test] + fn time_range_rejects_source_not_first_property() { + // `on` names a property that is not the first index property + let map = index_value_map("$createdAt", Some(("hashtag", 60, 20))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + #[test] + fn time_range_rejects_zero_step() { + let map = index_value_map("$createdAt", Some(("$createdAt", 60, 0))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + #[test] + fn time_range_rejects_null_searchable_false() { + let mut map = index_value_map("$createdAt", Some(("$createdAt", 21_600, 7_200))); + map.push(( + Value::Text("nullSearchable".to_string()), + Value::Bool(false), + )); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + #[test] + fn time_range_rejects_ranked_flags() { + // Ranked queries exclude bucketed indexes, so the combination would + // maintain ranked secondaries no query can ever select. The index is + // otherwise a fully valid ranked shape (single property, countable + + // rangeCountable), so the ranked-prerequisite checks pass and the + // rejection under test is the one that fires. + let property = Value::Map(vec![( + Value::Text("$createdAt".to_string()), + Value::Text("asc".to_string()), + )]); + let map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("busiestBuckets".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![property]), + ), + (Value::Text("countable".to_string()), Value::Bool(true)), + (Value::Text("rangeCountable".to_string()), Value::Bool(true)), + ( + Value::Text("rankedCountable".to_string()), + Value::Bool(true), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(21_600)), + (Value::Text("step".to_string()), Value::U64(7_200)), + ]), + ), + ]; + let err = Index::try_from_value_map(map.as_slice(), true, true).unwrap_err(); + match err { + DataContractError::InvalidContractStructure(message) => { + assert!( + message.contains("cannot be ranked"), + "expected the timeRange-vs-ranked rejection, got: {message}" + ); + } + other => panic!("expected InvalidContractStructure, got: {other:?}"), + } + } + + /// One day as the contract grammar declares a window (seconds), aligned to + /// the epoch: `range == step`, so the windows tile time without + /// overlapping and every document lands in exactly one. + const ONE_DAY_SECONDS: u64 = 24 * 3_600; + + #[test] + fn unique_time_range_index_parses_for_non_overlapping_windows_on_created_at() { + // "one post per author per day": the bucket is a genuine partition of + // time, so at most one document per (day, hashtag) is a constraint the + // storage layout can actually hold. + let mut map = index_value_map( + "$createdAt", + Some(("$createdAt", ONE_DAY_SECONDS, ONE_DAY_SECONDS)), + ); + map.push((Value::Text("unique".to_string()), Value::Bool(true))); + let index = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + assert!(index.unique, "the unique flag must survive parsing"); + let transform = index.time_range.expect("time_range should be set"); + assert_eq!(transform.source, "$createdAt"); + assert_eq!( + transform.overlap_factor(), + 1, + "range == step is what makes the index a partition" + ); + } + + #[test] + fn time_range_rejects_unique_when_windows_overlap() { + // range = 3 * step: each document is indexed under three bucket keys + // at once, so there is no single slot for uniqueness to guard. + let mut map = index_value_map( + "$createdAt", + Some(("$createdAt", 3 * ONE_DAY_SECONDS, ONE_DAY_SECONDS)), + ); + map.push((Value::Text("unique".to_string()), Value::Bool(true))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + match err { + DataContractError::InvalidContractStructure(message) => { + assert!( + message.contains("unique") && message.contains("non-overlapping"), + "expected the overlapping-windows rejection, got: {message}" + ); + } + other => panic!("expected InvalidContractStructure, got: {other:?}"), + } + } + + #[test] + fn time_range_rejects_unique_on_a_mutable_timestamp_source() { + // `$updatedAt` moves the document to a new bucket on every revision; + // uniqueness validation only sees the new timestamp, so it could never + // tell which bucket the document is vacating. + let mut map = index_value_map( + "$updatedAt", + Some(("$updatedAt", ONE_DAY_SECONDS, ONE_DAY_SECONDS)), + ); + map.push((Value::Text("unique".to_string()), Value::Bool(true))); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + match err { + DataContractError::InvalidContractStructure(message) => { + assert!( + message.contains("$createdAt"), + "expected the immutable-source requirement, got: {message}" + ); + } + other => panic!("expected InvalidContractStructure, got: {other:?}"), + } + // The same shape without `unique` is fine — the restriction is about + // uniqueness, not about bucketing `$updatedAt`. + let map = index_value_map( + "$updatedAt", + Some(("$updatedAt", ONE_DAY_SECONDS, ONE_DAY_SECONDS)), + ); + Index::try_from_value_map(map.as_slice(), false, true) + .expect("a non-unique $updatedAt bucketing stays legal"); + } + + #[test] + fn time_range_is_not_part_of_the_pre_v3_grammar() { + // Without the meta-schema-v3 grammar admission, `timeRange` falls + // through to the unknown-key arm — exactly how a pre-PV14 node + // rejected it. + let map = index_value_map("$createdAt", Some(("$createdAt", 21_600, 7_200))); + let err = Index::try_from(map.as_slice()).unwrap_err(); + assert!(matches!(err, DataContractError::ValueWrongType(_))); + let err = Index::try_from_value_map(map.as_slice(), true, false).unwrap_err(); + assert!(matches!(err, DataContractError::ValueWrongType(_))); + } + // ----------------------------------------------------------------------- // ContestedIndexResolution tests // ----------------------------------------------------------------------- @@ -2237,7 +2787,7 @@ mod tests { ("rankedSummable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .expect("all three ranked keywords must parse when the grammar allows them"); assert!(index.ranked_countable); assert!(index.ranked_summable); @@ -2255,7 +2805,7 @@ mod tests { ("averageable", Value::Text("score".to_string())), ("rangeAverageable", Value::Bool(true)), ]); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .expect("index without ranked keywords must parse"); assert!(!index.ranked_countable); assert!(!index.ranked_summable); @@ -2289,7 +2839,7 @@ mod tests { Value::Text("asc".to_string()), )]), ]); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .expect("ranked flags on a compound index must be accepted"); assert!(index.ranked_averageable); assert_eq!(index.properties.len(), 2); @@ -2311,7 +2861,7 @@ mod tests { Value::Text("asc".to_string()), )]), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "rankedCountable without rangeCountable must be rejected on a compound index too" @@ -2334,7 +2884,7 @@ mod tests { ("rangeAverageable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "ranked flags on a unique index must be rejected" @@ -2355,7 +2905,7 @@ mod tests { ("countable", Value::Text("countable".to_string())), ("rankedCountable", Value::Bool(true)), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "rankedCountable without rangeCountable must be rejected" @@ -2373,7 +2923,7 @@ mod tests { ("summable", Value::Text("score".to_string())), ("rankedSummable", Value::Bool(true)), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "rankedSummable without rangeSummable must be rejected" @@ -2393,7 +2943,7 @@ mod tests { ("rangeCountable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "rankedAverageable with only the count range axis must be rejected" @@ -2413,7 +2963,7 @@ mod tests { ("rangeSummable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "rankedAverageable with only the sum range axis must be rejected" @@ -2435,7 +2985,7 @@ mod tests { ("rangeAverageable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .expect("rankedAverageable on the averageable sugar form must parse"); assert!(index.ranked_averageable); assert!(index.countable.is_countable()); @@ -2463,7 +3013,7 @@ mod tests { ("rangeSummable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ]); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .expect("rankedAverageable on the explicit longhand form must parse"); assert!(index.ranked_averageable); assert!(index.range_countable); @@ -2481,7 +3031,7 @@ mod tests { ("rangeAverageable", Value::Bool(true)), (key, Value::Text("yes".to_string())), ]); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!(result.is_err(), "{key} must reject a non-boolean value"); let msg = format!("{:?}", result.unwrap_err()); assert!( @@ -2505,7 +3055,7 @@ mod tests { ("rangeAverageable", Value::Bool(true)), (key, value.clone()), ]); - let result = Index::try_from_value_map(index_map.as_slice(), false); + let result = Index::try_from_value_map(index_map.as_slice(), false, false); assert!( result.is_err(), "{key}: {value:?} must be rejected when the ranked grammar is off" @@ -2578,7 +3128,7 @@ mod tests { for (axis, mut extra) in ranked_axis_fixtures() { extra.push(("nullSearchable", Value::Bool(false))); let index_map = ranked_index_map(extra); - let result = Index::try_from_value_map(index_map.as_slice(), true); + let result = Index::try_from_value_map(index_map.as_slice(), true, true); assert!( result.is_err(), "{axis} with nullSearchable: false must be rejected" @@ -2598,7 +3148,7 @@ mod tests { fn test_index_try_from_ranked_without_null_searchable_key_accepted() { for (axis, extra) in ranked_axis_fixtures() { let index_map = ranked_index_map(extra); - let index = Index::try_from_value_map(index_map.as_slice(), true) + let index = Index::try_from_value_map(index_map.as_slice(), true, true) .unwrap_or_else(|e| panic!("{axis} with no nullSearchable key must parse: {e:?}")); assert!( index.null_searchable, @@ -2614,9 +3164,10 @@ mod tests { for (axis, mut extra) in ranked_axis_fixtures() { extra.push(("nullSearchable", Value::Bool(true))); let index_map = ranked_index_map(extra); - let index = Index::try_from_value_map(index_map.as_slice(), true).unwrap_or_else(|e| { - panic!("{axis} with an explicit nullSearchable: true must parse: {e:?}") - }); + let index = + Index::try_from_value_map(index_map.as_slice(), true, true).unwrap_or_else(|e| { + panic!("{axis} with an explicit nullSearchable: true must parse: {e:?}") + }); assert!(index.null_searchable); } } @@ -2627,7 +3178,7 @@ mod tests { #[test] fn test_index_try_from_non_ranked_with_null_searchable_false_still_accepted() { let plain = ranked_index_map(vec![("nullSearchable", Value::Bool(false))]); - let index = Index::try_from_value_map(plain.as_slice(), true) + let index = Index::try_from_value_map(plain.as_slice(), true, true) .expect("nullSearchable: false on a plain index must still parse"); assert!(!index.null_searchable); @@ -2636,7 +3187,7 @@ mod tests { ("rangeAverageable", Value::Bool(true)), ("nullSearchable", Value::Bool(false)), ]); - let index = Index::try_from_value_map(aggregating.as_slice(), true) + let index = Index::try_from_value_map(aggregating.as_slice(), true, true) .expect("nullSearchable: false on a range-averageable index must still parse"); assert!(!index.null_searchable); assert!(!index.ranked_averageable); @@ -3119,6 +3670,7 @@ mod json_convertible_tests { ranked_countable: true, ranked_summable: false, ranked_averageable: true, + time_range: None, } } @@ -3148,6 +3700,7 @@ mod json_convertible_tests { "ranked_countable": true, "ranked_summable": false, "ranked_averageable": true, + "time_range": serde_json::Value::Null, }) ); let recovered = Index::from_json(json).expect("from_json"); diff --git a/packages/rs-dpp/src/data_contract/document_type/index/random_index.rs b/packages/rs-dpp/src/data_contract/document_type/index/random_index.rs index a352c935e86..a8b20234adf 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/random_index.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/random_index.rs @@ -67,6 +67,7 @@ impl Index { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }) } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs new file mode 100644 index 00000000000..f9b190b6a4f --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs @@ -0,0 +1,392 @@ +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// An index-level transform that buckets a timestamp index property into +/// fixed-length, regularly-spaced time ranges. +/// +/// The window is **declared in seconds** and **identified in milliseconds**. +/// A contract author writes `range` / `step` / `origin` as second counts +/// because the finest clock a bucket selection ever sees is block time, whose +/// target interval is five seconds — a window declared to the millisecond +/// would be precision the protocol cannot deliver. A time range is still +/// identified by a single `u64`: the **start time of the range** as a +/// millisecond timestamp, because the source fields (`$createdAt` &co.) are +/// millisecond timestamps and the stored index key has to stay directly +/// comparable to them. [`Self::range_ms`] and its siblings are the one place +/// the two units meet. +/// +/// Each range covers `[start, start + range)`. New ranges start every `step`. +/// When `range > step` the ranges overlap, so a single timestamp falls into +/// `range / step` ranges (the "overlap factor") and a document is indexed +/// under that many bucket-start values. +/// +/// The canonical use case is "trending" leaderboards: index on +/// `(timeRange($createdAt), hashtag)` with `countable`, then query a single +/// bucket — e.g. per-hashtag counts within the bucket (`COUNT(*)` grouped by +/// `hashtag`, with the client ordering the returned groups). Overlapping +/// ranges guarantee that, at any instant, there is always an active range +/// covering a near-full `range` window of history (see +/// [`Self::oldest_active_start`]). +/// +/// Note that the *server-ordered* form (`ORDER BY COUNT(*)` — the ranked +/// query surface) cannot yet be combined with a time-range selection: ranked +/// queries accept no where clauses in this protocol version (their routing +/// deliberately has no equality-prefix support yet), so "top K by count +/// within the bucket" is served as the grouped count above with client-side +/// ordering until ranked prefix routing lands at a future protocol version. +/// +/// This transform lives on the index definition only. At the GroveDB storage +/// layer a bucket start is an ordinary `u64` key segment (encoded exactly +/// like a `$createdAt` value), so existing index queries, count trees and +/// proofs apply unchanged — the only novelty is that one document produces +/// several index entries. +// The serde keys deliberately match the contract grammar (`on` / `range` / +// `step` / `origin`, see the `timeRange` entry in the v3 document +// meta-schema), so a serialized `Index` round-trips into the same key set a +// contract author writes rather than a second, camelCased spelling. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde-conversion", derive(Serialize, Deserialize))] +pub struct TimeRangeTransform { + /// The source timestamp index property this transform buckets. Must be + /// the first property of the index and must name one of the system + /// timestamps (`$createdAt` / `$updatedAt` / `$transferredAt`); the + /// document-schema grammar has no user property type that parses to a + /// millisecond timestamp, so user-defined sources are rejected at + /// contract validation until such a representation exists. + #[cfg_attr(feature = "serde-conversion", serde(rename = "on"))] + pub source: String, + /// Length of each range window, in seconds. Must be a positive multiple + /// of `step_seconds`. The window a document is measured against is this + /// length expressed in milliseconds ([`Self::range_ms`]), since bucket + /// starts are millisecond timestamps. + #[cfg_attr(feature = "serde-conversion", serde(rename = "range"))] + pub range_seconds: u64, + /// Interval between successive range starts, in seconds. Must be greater + /// than zero. Consecutive bucket starts are [`Self::step_ms`] apart on the + /// millisecond timeline. + #[cfg_attr(feature = "serde-conversion", serde(rename = "step"))] + pub step_seconds: u64, + /// Reference origin for range alignment, in seconds. Range starts are the + /// millisecond timestamps `origin_ms() + k * step_ms()` for + /// `k = 0, 1, 2, …`. Defaults to `0`. + #[cfg_attr(feature = "serde-conversion", serde(rename = "origin", default))] + pub origin_seconds: u64, +} + +impl TimeRangeTransform { + /// The window length on the millisecond timeline. + /// + /// The three `*_ms` accessors are the single crossing point between the + /// transform's two units: the parameters are seconds because that is the + /// resolution a contract author can meaningfully declare, while every + /// quantity the bucket math consumes or produces — a document's + /// `$createdAt`, a bucket start, a stored index key — is a millisecond + /// timestamp, so the parameters have to be scaled before they can take + /// part. + /// + /// Saturating rather than checked: contract validation rejects any + /// parameter above `u64::MAX / 1_000`, so a transform that came from a + /// validated contract can never reach the ceiling. A transform built + /// outside validation degrades into a pinned-to-the-maximum window + /// instead of panicking — the same defensive posture as the + /// `step_seconds == 0` handling below, which returns `None` / an empty + /// bucket set rather than dividing by zero. + pub fn range_ms(&self) -> u64 { + self.range_seconds.saturating_mul(1_000) + } + + /// The interval between successive range starts on the millisecond + /// timeline. See [`Self::range_ms`] for why the accessor exists and why + /// it saturates. + pub fn step_ms(&self) -> u64 { + self.step_seconds.saturating_mul(1_000) + } + + /// The alignment origin on the millisecond timeline — the first range's + /// start. See [`Self::range_ms`] for why the accessor exists and why it + /// saturates. + pub fn origin_ms(&self) -> u64 { + self.origin_seconds.saturating_mul(1_000) + } + + /// The number of overlapping ranges that contain any given instant, i.e. + /// the number of bucket-start values a single document is indexed under. + /// Equal to `range / step`. + /// + /// A ratio of two same-unit quantities, so it is unit-invariant and reads + /// the declared seconds directly rather than scaling both sides first. + /// + /// Returns `0` only for a malformed transform with a zero step; callers + /// constructing from a validated contract never observe that. + pub fn overlap_factor(&self) -> u64 { + if self.step_seconds == 0 { + return 0; + } + self.range_seconds / self.step_seconds + } + + /// The start of the most recent range that has begun at or before the + /// millisecond timestamp `t`, i.e. the largest `origin + k * step` that is + /// `<= t`. + /// + /// Returns `None` for `t` before the origin: no range has started yet, and + /// the first range's window `[origin, origin + range)` does not contain + /// such a `t`, so there is no honest answer. (Also `None` for a malformed + /// zero-step transform, which a validated contract can never carry.) + pub fn most_recent_start(&self, t: u64) -> Option { + let (step_ms, origin_ms) = (self.step_ms(), self.origin_ms()); + if step_ms == 0 || t < origin_ms { + return None; + } + let elapsed = t - origin_ms; + Some(origin_ms + (elapsed / step_ms) * step_ms) + } + + /// All bucket-start values whose range `[start, start + range)` contains + /// the millisecond timestamp `t`. This is the set of index entries a + /// document with timestamp `t` must be written under. + /// + /// The result is sorted in descending order (newest range first) and has + /// exactly [`Self::overlap_factor`] elements, except near the origin where + /// fewer ranges have started. For `t` before the origin the result is + /// empty: the timestamp predates every range, so the document is not + /// indexed under any bucket (insert, delete and update all share this + /// rule, keeping the index consistent). + pub fn containing_buckets(&self, t: u64) -> Vec { + let overlap = self.overlap_factor(); + if overlap == 0 { + return Vec::new(); + } + let Some(newest) = self.most_recent_start(t) else { + return Vec::new(); + }; + let (step_ms, origin_ms) = (self.step_ms(), self.origin_ms()); + (0..overlap) + .filter_map(|j| { + let offset = j.checked_mul(step_ms)?; + newest.checked_sub(offset) + }) + .filter(|start| *start >= origin_ms) + .collect() + } + + /// The start of the newest range that is active at the millisecond + /// timestamp `now` (the freshest started range). Querying this bucket + /// returns documents from the latest partial slice — between `0` and one + /// `step` of history. + /// + /// Returns `None` when `now` predates the origin: no range has started + /// yet, so there is no active bucket to query. + pub fn newest_active_start(&self, now: u64) -> Option { + self.most_recent_start(now) + } + + /// The start of the oldest range still active at the millisecond timestamp + /// `now`. Its window `[start, start + range)` still contains `now`, so + /// querying this bucket returns a near-full trailing window of `~range` of + /// history (between `range - step` and `range`). This is the bucket to + /// query for "trending over the last range window". + /// + /// Returns `None` when `now` predates the origin (no range has started + /// yet). + pub fn oldest_active_start(&self, now: u64) -> Option { + let overlap = self.overlap_factor(); + if overlap == 0 { + return None; + } + let newest = self.most_recent_start(now)?; + let back = (overlap - 1).saturating_mul(self.step_ms()); + Some(newest.saturating_sub(back).max(self.origin_ms())) + } + + /// The set of index-entry keys a document with the given raw encoded + /// value for the bucketed property must be stored under. + /// + /// **This is the single source of truth for the fan-out rule**: the + /// insert, delete and update walkers in rs-drive must all derive their + /// entry keys through this function, or a document written by one walker + /// becomes unfindable by another (a consensus break on + /// update-after-insert). + /// + /// - An empty `raw` (the null / property-absent case) keeps the single + /// ordinary null entry every index gives null values. + /// - A decodable millisecond timestamp yields one key per containing + /// bucket — the bucket *start*, encoded exactly like the timestamp + /// itself — and **no keys at all** when the timestamp predates the + /// origin (it belongs to no range; such a document is not present in + /// this index). + /// - A non-empty value that fails to decode keeps its raw key, exactly as + /// a non-time-range index would store it. + pub fn entry_keys_for_raw(&self, raw: &[u8]) -> Vec> { + use crate::data_contract::document_type::DocumentPropertyType; + if raw.is_empty() { + return vec![Vec::new()]; + } + match DocumentPropertyType::decode_date_timestamp(raw) { + Some(timestamp) => self + .containing_buckets(timestamp) + .into_iter() + .map(DocumentPropertyType::encode_date_timestamp) + .collect(), + None => vec![raw.to_vec()], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One hour as the transform declares it (seconds) and as every timestamp + /// below is expressed (milliseconds). + const HOUR_SECONDS: u64 = 3_600; + const HOUR_MS: u64 = 3_600_000; + + fn transform() -> TimeRangeTransform { + // range = 6h, step = 2h, origin = 0 → overlap factor 3. + TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 2 * HOUR_SECONDS, + origin_seconds: 0, + } + } + + #[test] + fn overlap_factor_is_range_over_step() { + assert_eq!(transform().overlap_factor(), 3); + } + + #[test] + fn seconds_that_cannot_be_scaled_saturate_rather_than_panic() { + // Contract validation refuses parameters this large, so the only way + // to build one is in code; the accessors must degrade into a pinned + // window instead of panicking on the multiplication. + let t = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: u64::MAX, + step_seconds: u64::MAX, + origin_seconds: 0, + }; + assert_eq!(t.range_ms(), u64::MAX); + assert_eq!(t.overlap_factor(), 1); + assert_eq!(t.most_recent_start(u64::MAX), Some(u64::MAX)); + } + + #[test] + fn most_recent_start_floors_to_step_multiple() { + let t = transform(); + let h = HOUR_MS; + // now = 7h → most recent start = 6h + assert_eq!(t.most_recent_start(7 * h), Some(6 * h)); + // exactly on a boundary stays put + assert_eq!(t.most_recent_start(6 * h), Some(6 * h)); + // exactly at the origin is the first range + assert_eq!(t.most_recent_start(0), Some(0)); + } + + #[test] + fn pre_origin_timestamps_have_no_buckets() { + // A one-minute window stepping every twenty seconds, its grid anchored + // at the 1_000-second mark — 1_000_000 ms into the epoch. + let t = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 60, + step_seconds: 20, + origin_seconds: 1_000, + }; + // a timestamp before the origin belongs to no range: it must not be + // indexed under any bucket, and no range is active yet + assert_eq!(t.most_recent_start(999_999), None); + assert_eq!(t.containing_buckets(999_999), Vec::::new()); + assert_eq!(t.newest_active_start(999_999), None); + assert_eq!(t.oldest_active_start(999_999), None); + // at the origin the first range starts + assert_eq!(t.most_recent_start(1_000_000), Some(1_000_000)); + assert_eq!(t.containing_buckets(1_000_000), vec![1_000_000]); + // every returned bucket actually contains the timestamp + for now in [1_000_000u64, 1_010_000, 1_059_000, 1_100_000] { + for start in t.containing_buckets(now) { + assert!(start <= now && now < start + t.range_ms()); + } + } + } + + #[test] + fn containing_buckets_are_the_overlapping_ranges() { + let t = transform(); + let h = HOUR_MS; + // doc at 7h belongs to ranges starting at 6h, 4h, 2h + assert_eq!(t.containing_buckets(7 * h), vec![6 * h, 4 * h, 2 * h]); + // every returned range actually contains the timestamp + for start in t.containing_buckets(7 * h) { + assert!(start <= 7 * h && 7 * h < start + t.range_ms()); + } + } + + #[test] + fn containing_buckets_truncate_near_origin() { + let t = transform(); + let h = HOUR_MS; + // doc at 3h: ranges starting at 2h and 0h (4h start would be in future) + assert_eq!(t.containing_buckets(3 * h), vec![2 * h, 0]); + } + + #[test] + fn newest_vs_oldest_active() { + let t = transform(); + let h = HOUR_MS; + let now = 7 * h; + // newest active = freshest started range + assert_eq!(t.newest_active_start(now), Some(6 * h)); + // oldest active = covers the full trailing window + assert_eq!(t.oldest_active_start(now), Some(2 * h)); + // oldest active range still contains now + let oldest = t.oldest_active_start(now).expect("a range is active"); + assert!(oldest <= now && now < oldest + t.range_ms()); + } + + #[test] + fn entry_keys_follow_the_shared_fan_out_rule() { + use crate::data_contract::document_type::DocumentPropertyType; + let t = transform(); + let h = HOUR_MS; + // null keeps the single ordinary null entry + assert_eq!(t.entry_keys_for_raw(&[]), vec![Vec::::new()]); + // a decodable timestamp fans out into its containing buckets + let raw = DocumentPropertyType::encode_date_timestamp(7 * h); + assert_eq!( + t.entry_keys_for_raw(&raw), + vec![ + DocumentPropertyType::encode_date_timestamp(6 * h), + DocumentPropertyType::encode_date_timestamp(4 * h), + DocumentPropertyType::encode_date_timestamp(2 * h), + ] + ); + // an undecodable non-empty value keeps its raw key + assert_eq!(t.entry_keys_for_raw(&[1, 2, 3]), vec![vec![1, 2, 3]]); + // a pre-origin timestamp belongs to no range: no keys + let t_offset = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 60, + step_seconds: 20, + origin_seconds: 1_000, + }; + let raw = DocumentPropertyType::encode_date_timestamp(999_999); + assert_eq!(t_offset.entry_keys_for_raw(&raw), Vec::>::new()); + } + + #[test] + fn origin_offset_shifts_alignment() { + let t = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 60, + step_seconds: 20, + origin_seconds: 5, + }; + // starts are the 5th, 25th, 45th, ... second; now = the 50th second → + // most recent start is the 45th + assert_eq!(t.most_recent_start(50_000), Some(45_000)); + assert_eq!(t.containing_buckets(50_000), vec![45_000, 25_000, 5_000]); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs b/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs index 80b37ef390f..4939d1aad13 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs @@ -159,4 +159,40 @@ impl IndexLevel { None } + + /// Time-range counterpart of [`Self::find_first_countability_change`]. + /// Recursively finds the first index path where the `time_range` + /// transform differs between two `IndexLevel` trees. The transform + /// dictates how many index entries each document produces and under + /// which bucket keys, so changing it after creation would leave already + /// stored documents indexed under stale buckets — it is immutable. + /// + /// Returns `None` if the transform is the same everywhere. + #[cfg(feature = "validation")] + pub(super) fn find_first_time_range_change(&self, new: &IndexLevel) -> Option { + if self.time_range() != new.time_range() { + let fmt = |t: Option<&super::TimeRangeTransform>| match t { + Some(t) => format!( + "Some(on: {:?}, range: {}s, step: {}s, origin: {}s)", + t.source, t.range_seconds, t.step_seconds, t.origin_seconds + ), + None => "None".to_string(), + }; + return Some(format!( + "(timeRange: {} -> {})", + fmt(self.time_range()), + fmt(new.time_range()), + )); + } + + for (key, old_sub) in &self.sub_index_levels { + if let Some(new_sub) = new.sub_index_levels.get(key) { + if let Some(inner_path) = old_sub.find_first_time_range_change(new_sub) { + return Some(format!("{} -> {}", key, inner_path)); + } + } + } + + None + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs index 77295ca0eae..0baadf8966a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs @@ -7,10 +7,12 @@ use crate::consensus::basic::data_contract::DuplicateIndexError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::document_type::index::IndexCountability; +use crate::data_contract::document_type::index::TimeRangeTransform; use crate::data_contract::document_type::index_level::IndexType::{ ContestedResourceIndex, NonUniqueIndex, UniqueIndex, }; use crate::data_contract::document_type::Index; +use crate::data_contract::errors::DataContractError; #[cfg(feature = "validation")] use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; @@ -121,6 +123,14 @@ pub struct IndexLevel { sub_index_levels: BTreeMap, /// did an index terminate at this level has_index_with_type: Option, + /// When set, the property reached at this level is a timestamp that is + /// bucketed into time ranges (see [`TimeRangeTransform`]). Only ever set + /// on a *first-property* node (a direct child of the root), because a + /// time-range transform must be its index's leading property. At + /// insert/delete/update time the document's timestamp for this property + /// is expanded into one key per overlapping range bucket instead of a + /// single key. Immutable after contract creation. + time_range: Option, /// unique level identifier level_identifier: u64, } @@ -134,6 +144,12 @@ impl IndexLevel { &self.sub_index_levels } + /// The time-range transform applied to the property reached at this + /// level, if any. Only set on first-property nodes. + pub fn time_range(&self) -> Option<&TimeRangeTransform> { + self.time_range.as_ref() + } + pub fn has_index_with_type(&self) -> Option<&IndexLevelTypeInfo> { // Was `Option` (Copy) before the v3 sum-tree // expansion added `summable: Option` to the struct, which @@ -235,17 +251,30 @@ impl IndexLevel { let mut index_level = IndexLevel { sub_index_levels: Default::default(), has_index_with_type: None, + time_range: None, level_identifier: 0, }; let mut counter: u64 = 0; + // First-property nodes that have already been visited, with the + // transform (or absence of one) their first visitor recorded. All + // indices sharing a first property must agree on its time-range + // transform — the walkers read the transform off the *merged* node, + // so a disagreement would bucket one index's entries and not + // another's. `try_from_schema` also rejects this under full + // validation; enforcing it here as well covers every construction + // path (check_tx, deserialized state, hand-built document types) + // instead of silently letting the last writer win. + let mut first_property_transforms: BTreeMap> = + BTreeMap::new(); + for index_to_borrow in indices { let index = index_to_borrow.borrow(); let mut current_level = &mut index_level; - let mut properties_iter = index.properties.iter().peekable(); + let mut properties_iter = index.properties.iter().enumerate().peekable(); - while let Some(index_part) = properties_iter.next() { + while let Some((position, index_part)) = properties_iter.next() { current_level = current_level .sub_index_levels .entry(index_part.name.clone()) @@ -255,9 +284,37 @@ impl IndexLevel { level_identifier: counter, sub_index_levels: Default::default(), has_index_with_type: None, + time_range: None, } }); + // A time-range transform always targets the index's first + // property, so record it on that first-property node — + // rejecting any disagreement between indices that share it + // (see `first_property_transforms` above). + if position == 0 { + match first_property_transforms.get(&index_part.name) { + Some(existing) if *existing != index.time_range => { + return Err(ProtocolError::DataContractError( + DataContractError::InvalidContractStructure(format!( + "indices that share the first property \"{}\" must agree on \ + its timeRange transform: either all bucket it identically \ + or none do", + index_part.name + )), + )); + } + Some(_) => {} + None => { + first_property_transforms + .insert(index_part.name.clone(), index.time_range.clone()); + } + } + if let Some(transform) = &index.time_range { + current_level.time_range = Some(transform.clone()); + } + } + // The last property if properties_iter.peek().is_none() { // This level already has been initialized. @@ -399,6 +456,20 @@ impl IndexLevel { ); } + // A time-range transform determines how many index entries each + // document produces and under which bucket keys. Changing it after + // creation would leave already-stored documents indexed under stale + // buckets, so it is immutable — reject any change. + if let Some(time_range_change_path) = self.find_first_time_range_change(new_indices) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + document_type_name.to_string(), + time_range_change_path, + ) + .into(), + ); + } + SimpleConsensusValidationResult::new() } } @@ -430,6 +501,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -464,6 +536,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![ @@ -483,6 +556,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }, Index { name: "test2".to_string(), @@ -500,6 +574,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }, ]; @@ -543,6 +618,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }, Index { name: "test2".to_string(), @@ -560,6 +636,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }, ]; @@ -579,6 +656,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -620,6 +698,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -644,6 +723,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -691,6 +771,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -709,6 +790,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -750,6 +832,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -768,6 +851,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -809,6 +893,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -827,6 +912,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -868,6 +954,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -909,6 +996,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -927,6 +1015,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -968,6 +1057,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -986,6 +1076,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -1033,6 +1124,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -1057,6 +1149,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -1104,6 +1197,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let new_indices = vec![Index { @@ -1128,6 +1222,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let old_index_structure = @@ -1183,6 +1278,7 @@ mod tests { ranked_countable, ranked_summable, ranked_averageable, + time_range: None, } } @@ -1339,6 +1435,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }]; let mut new_indices = old_indices.clone(); diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index fd773b07c5f..9a10bfaa296 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -95,6 +95,41 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } } + /// [`Self::index_for_types`] restricted to the indexes `filter` admits. + /// + /// Candidates rejected by `filter` are skipped before they are scored, so + /// they can never be returned. This is the only correct way to require a + /// property of the selected index: because several indexes can cover the + /// same fields and ties are broken by the index map's name ordering, + /// checking the property after an unrestricted search can reject the + /// winner but cannot surface the index the caller actually needed. + /// + /// Shares the `index_for_types` feature-version gate — the filter narrows + /// the candidate set, it does not change how a candidate is scored. + fn index_for_types_matching( + &self, + index_names: &[&str], + in_field_name: Option<&str>, + order_by: &[&str], + filter: impl Fn(&Index) -> bool, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .index_for_types + { + 0 => Ok(self.index_for_types_matching_v0(index_names, in_field_name, order_by, filter)), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "index_for_types_matching".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + fn serialize_value_for_key( &self, key: &str, diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 1501184adfb..0c882423d35 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -403,10 +403,32 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa index_names: &[&str], in_field_name: Option<&str>, order_by: &[&str], + ) -> Option<(&Index, u16)> { + self.index_for_types_matching_v0(index_names, in_field_name, order_by, |_| true) + } + + /// [`Self::index_for_types_v0`] restricted to the indexes `filter` admits. + /// + /// The filter is applied before `Index::matches`, so a rejected index can + /// never win the difference comparison. Callers that need selection pinned + /// to a class of index must use this rather than post-checking whatever the + /// unrestricted search returned: several indexes can cover the same fields, + /// the winner among equally-good candidates is decided by the index map's + /// name ordering, and a post-check can only reject the winner — never + /// promote the index that was actually required. + fn index_for_types_matching_v0( + &self, + index_names: &[&str], + in_field_name: Option<&str>, + order_by: &[&str], + filter: impl Fn(&Index) -> bool, ) -> Option<(&Index, u16)> { let mut best_index: Option<(&Index, u16)> = None; let mut best_difference = u16::MAX; for (_, index) in self.indexes().iter() { + if !filter(index) { + continue; + } let difference_option = index.matches(index_names, in_field_name, order_by); if let Some(difference) = difference_option { if difference == 0 { diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs index f1522e5f888..c52b08c235b 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs @@ -112,23 +112,26 @@ impl DataContractInSerializationFormat { ) { for index_value in index_values { if let Ok(index_value_map) = index_value.to_map() { - // Same ranked-keyword gate the document type parser - // applies (`document_type_schema >= 3`, i.e. meta - // schema v3 / protocol version 14). Without it a - // PV14 index carrying `rankedCountable` &co. would - // fail to parse here and be billed nothing, while - // the identical index parses fine during - // validation — the fee must cover every index the - // contract actually registers. + // Same ranked-keyword / timeRange gate the document + // type parser applies (`document_type_schema >= 3`, + // i.e. meta schema v3 / protocol version 14). + // Without it a PV14 index carrying + // `rankedCountable` &co. or `timeRange` would fail + // to parse here and be billed nothing, while the + // identical index parses fine during validation — + // the fee must cover every index the contract + // actually registers. + let meta_schema_v3_grammar = platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .document_type_schema + >= 3; if let Ok(index) = Index::try_from_value_map( index_value_map.as_slice(), - platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .document_type_schema - >= 3, + meta_schema_v3_grammar, + meta_schema_v3_grammar, ) { let base_index_fee = if index.contested_index.is_some() { fee_version.document_type_base_contested_index_registration_fee diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs index bd37b545712..6ce8b55844d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs @@ -64,6 +64,7 @@ impl Platform { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs index 384aa8781fe..c0a6beff292 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs @@ -248,6 +248,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation @@ -339,6 +340,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs index 06fec8661c2..5f66cc05563 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs @@ -241,6 +241,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Diff vs `_v0` (parent-domain query): @@ -354,6 +355,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Diff vs `_v0` (preorder query): same change as above. `_v0` diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs index ccf7a81837f..10d0750bcb2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs @@ -77,6 +77,7 @@ pub(super) fn delete_withdrawal_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs index 66c14377bdc..59b4cb4e641 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs @@ -71,6 +71,7 @@ pub(super) fn delete_withdrawal_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Diff vs `_v0` (withdrawal-document lookup): diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index 8a5b5a7d41b..93f2e129fdb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -122,6 +122,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v0( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation @@ -182,6 +183,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v1( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via @@ -307,6 +309,7 @@ fn fetch_document_with_id_v0( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation @@ -369,6 +372,7 @@ fn fetch_document_with_id_v1( start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs index 7a94babf225..94848417b8f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs @@ -456,6 +456,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let documents = platform @@ -503,6 +504,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let documents = platform @@ -911,6 +913,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let documents = platform @@ -945,6 +948,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let documents = platform @@ -1178,6 +1182,7 @@ mod dpns_username_transfer_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; platform @@ -1273,6 +1278,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, + resolved_time_range_fields: vec![], }; match platform @@ -1316,6 +1322,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, + resolved_time_range_fields: vec![], }; match platform @@ -1359,6 +1366,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, + resolved_time_range_fields: vec![], }; match platform @@ -1426,6 +1434,7 @@ mod dpns_username_transfer_tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 91344794871..b038b076eba 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -2600,6 +2600,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), @@ -2982,6 +2983,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), diff --git a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs index ff5a9ab1686..850adaa615a 100644 --- a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs @@ -125,6 +125,9 @@ impl Platform { data_contract_id, document_type_name, where_clauses, + // The v0 wire has no time-range operator, so nothing on this path + // can carry a resolved bucket equality. + Vec::new(), order_by_clauses, // v0 wire's `uint32` limit: `0` is the sentinel for // "use server default"; `> u16::MAX` is rejected. @@ -157,6 +160,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: Vec, order_by_clauses: Vec, limit_u32: Option, prove: bool, @@ -233,7 +237,7 @@ impl Platform { Some(n) => Some(n as u16), }; - let drive_query = + let mut drive_query = check_validation_result_with_data!(DriveDocumentQuery::from_typed_clauses( where_clauses, order_by_clauses, @@ -246,6 +250,11 @@ impl Platform { &self.config.drive, platform_version, )); + // Clause parsing cannot tell a resolved bucket equality from a + // hand-written one, so the provenance the v1 handler established is + // attached here; index selection reads it to pin the query to the + // index that buckets the field. + drive_query.resolved_time_range_fields = resolved_time_range_fields; let response = if prove { let proof = @@ -644,6 +653,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let request = GetDocumentsRequestV0 { @@ -717,6 +727,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let request = GetDocumentsRequestV0 { @@ -802,6 +813,7 @@ mod tests { start_at: Some(after), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let request = GetDocumentsRequestV0 { @@ -974,6 +986,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1141,6 +1154,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1296,6 +1310,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1462,6 +1477,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1645,6 +1661,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let mut where_clauses: Vec<_> = drive_document_query diff --git a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs index fa5c4da140b..59c3ce9632f 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs @@ -38,7 +38,7 @@ use dapi_grpc::platform::v0::get_documents_request::{ use dpp::platform_value::Value; use drive::query::{ HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, - OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, + OrderClause, SelectFunction, SelectProjection, TimeRangeSelector, WhereClause, WhereOperator, }; /// Map a wire-level [`ProtoWhereOperator`] discriminant onto @@ -49,7 +49,7 @@ use drive::query::{ pub(super) fn where_operator_from_proto(op: i32) -> Result { let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { QueryError::InvalidArgument(format!( - "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ + "unknown WhereOperator discriminant: {} (valid values: 0..=11, see \ `get_documents_request::WhereOperator`)", op )) @@ -66,9 +66,54 @@ pub(super) fn where_operator_from_proto(op: i32) -> Result WhereOperator::BetweenExcludeRight, ProtoWhereOperator::In => WhereOperator::In, ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, + // IN_TIME_RANGE is not an engine operator: it's resolved to a concrete + // equality from authoritative block time before clause conversion (see + // `is_time_range_clause` / `time_range_clause_from_proto`), so it must + // never reach this mapping. + ProtoWhereOperator::InTimeRange => { + return Err(QueryError::InvalidArgument( + "IN_TIME_RANGE where clauses are resolved from block time before \ + operator conversion and must not be mixed into normal clause decoding" + .to_string(), + )) + } }) } +/// Whether a wire where clause is a time-range selection +/// (`operator == IN_TIME_RANGE`). The v1 handler partitions these out and +/// resolves them from authoritative block time via +/// [`time_range_clause_from_proto`]. +pub(super) fn is_time_range_clause(clause: &ProtoWhereClause) -> bool { + clause.operator == ProtoWhereOperator::InTimeRange as i32 +} + +/// Decode an `IN_TIME_RANGE` wire where clause into its `(field, selector)`. +/// The operand carries the selector as `DocumentFieldValue.text` +/// (`"newest"` or `"oldest"`). +pub(super) fn time_range_clause_from_proto( + clause: ProtoWhereClause, +) -> Result<(String, TimeRangeSelector), QueryError> { + let field = clause.field; + let selector_text = match clause.value.and_then(|v| v.variant) { + Some(document_field_value::Variant::Text(s)) => s, + _ => { + return Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE clause on field '{}' must carry a text operand of \ + \"newest\" or \"oldest\"", + field + ))) + } + }; + let selector = TimeRangeSelector::from_string(&selector_text).ok_or_else(|| { + QueryError::InvalidArgument(format!( + "IN_TIME_RANGE selector must be \"newest\" or \"oldest\", got \"{}\"", + selector_text + )) + })?; + Ok((field, selector)) +} + /// Map a wire [`ProtoDocumentFieldValue`] onto a /// `dpp::platform_value::Value`. Schema-agnostic — variants map /// 1:1 by primitive type and recurse for `list` up to a depth of diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs index 33a53487d65..e92178868b6 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs @@ -46,6 +46,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -102,6 +103,7 @@ impl Platform { document_type, sum_property, where_clauses, + resolved_time_range_fields, order_clauses, mode: avg_mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs index 7af8f56ef41..4d07cd68bb3 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs @@ -42,6 +42,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -88,6 +89,7 @@ impl Platform { contract: contract_ref, document_type, where_clauses, + resolved_time_range_fields, order_clauses, mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs index 9dd9419363c..1a3397bfb07 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs @@ -28,6 +28,7 @@ impl Platform { data_contract_id: Vec, document_type: String, where_clauses: Vec, + resolved_time_range_fields: Vec, order_by_clauses: Vec, limit: Option, start: Option, @@ -48,6 +49,7 @@ impl Platform { data_contract_id, document_type, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, prove, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs index 2d75bc4b4f1..ed459465130 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs @@ -60,6 +60,7 @@ impl Platform { group_by: Vec, having: Vec, where_clauses: Vec, + resolved_time_range_fields: Vec, order_clauses: Vec, limit: Option, offset: Option, @@ -103,6 +104,7 @@ impl Platform { having: &having, order_by: &order_clauses, where_clauses: &where_clauses, + resolved_time_range_fields: &resolved_time_range_fields, limit, offset, has_start_at: start.is_some(), diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs index 5a495cef98d..88dcafdef68 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs @@ -44,6 +44,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -102,6 +103,7 @@ impl Platform { document_type, sum_property, where_clauses, + resolved_time_range_fields, order_clauses, mode: sum_mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs index 4a7fe596e1c..f09701edd18 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs @@ -55,9 +55,14 @@ use crate::error::query::QueryError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::query::QueryValidationResult; use dapi_grpc::platform::v0::get_documents_request::GetDocumentsRequestV1; use dapi_grpc::platform::v0::get_documents_response::GetDocumentsResponseV1; +use dpp::check_validation_result_with_data; +use dpp::data_contract::accessors::v0::DataContractV0Getters as _; +use dpp::prelude::Identifier as ContractIdentifier; +use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; use drive::query::{CountMode, SelectProjection}; @@ -182,10 +187,94 @@ impl Platform { // `InvalidArgument` rather than being masked by the blanket // "not yet implemented", since malformed input is malformed // regardless of which capability would have handled it. - let where_clauses = match conversions::where_clauses_from_proto(proto_where_clauses) { + // + // Time-range (IN_TIME_RANGE) clauses are partitioned out first. They + // are resolved to concrete equality clauses on the bucketed source + // field using the authoritative committed block time, so the rest of + // the v1 pipeline (routing, executors, proofs) treats them as + // ordinary equality lookups. The verifier re-derives the same bucket + // from the quorum-signed response metadata time, so the proof + // matches. + let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = proto_where_clauses + .into_iter() + .partition(conversions::is_time_range_clause); + + let mut where_clauses = match conversions::where_clauses_from_proto(normal_proto) { Ok(c) => c, Err(e) => return Ok(QueryValidationResult::new_with_error(e)), }; + let mut resolved_time_range_fields: Vec = Vec::new(); + + if !time_range_proto.is_empty() { + // LOAD-BEARING TIME SOURCE: the verifier re-derives the bucket + // from the response metadata's `time_ms`, which + // `response_metadata_v0` stamps from the state selected by + // `CheckpointUsed`. Reading `platform_state` here matches that + // only because every v1 document dispatch serves from + // `GroveDBToUse::Current`. If any document route ever serves + // from a checkpoint, this resolution must read the SAME state + // the response metadata will be built from, or every time-range + // proof will fail client-side verification. + let block_time_ms = + match platform_state.last_committed_block_time_ms() { + Some(t) => t, + None => return Ok(QueryValidationResult::new_with_error(QueryError::Query( + QuerySyntaxError::Unsupported( + "a time range (IN_TIME_RANGE) query requires a committed block time" + .to_string(), + ), + ))), + }; + let contract_id: ContractIdentifier = + check_validation_result_with_data!(data_contract_id.clone().try_into().map_err( + |_| QueryError::InvalidArgument( + "id must be a valid identifier (32 bytes long)".to_string() + ) + )); + let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( + contract_id.to_buffer(), + None, + true, + None, + platform_version, + )?; + let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info + .ok_or(QueryError::Query(QuerySyntaxError::DataContractNotFound( + "contract not found when resolving a time range query", + )))); + let contract_ref = &contract_fetch_info.contract; + let doc_type = check_validation_result_with_data!(contract_ref + .document_type_for_name(document_type.as_str()) + .map_err(|_| QueryError::InvalidArgument(format!( + "document type {} not found for contract {}", + document_type, contract_id + )))); + for proto_wc in time_range_proto { + let (field, selector) = match conversions::time_range_clause_from_proto(proto_wc) { + Ok(parsed) => parsed, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; + match drive::query::resolve_time_range_bucket_clause( + &field, + selector, + doc_type, + block_time_ms, + ) { + Ok(resolved) => { + where_clauses.push(resolved); + // The resolved clause is an ordinary equality; only + // this list tells the executors that it must be + // matched against bucket starts rather than raw + // timestamps, so it travels with the request. + resolved_time_range_fields.push(field); + } + Err(drive::error::Error::Query(qe)) => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query(qe))) + } + Err(e) => return Err(e.into()), + } + } + } let order_by_clauses = match conversions::order_clauses_from_proto(proto_order_by) { Ok(c) => c, Err(e) => return Ok(QueryValidationResult::new_with_error(e)), @@ -242,6 +331,7 @@ impl Platform { data_contract_id, document_type, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, start, @@ -253,6 +343,7 @@ impl Platform { data_contract_id, document_type, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, start, @@ -265,6 +356,7 @@ impl Platform { data_contract_id, document_type, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, start, @@ -278,6 +370,7 @@ impl Platform { data_contract_id, document_type, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, start, @@ -294,6 +387,7 @@ impl Platform { group_by, having_clauses, where_clauses, + resolved_time_range_fields, order_by_clauses, limit, offset, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/routing.rs b/packages/rs-drive-abci/src/query/document_query/v1/routing.rs index f686ffb47f4..91dd096e96c 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/routing.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/routing.rs @@ -368,7 +368,22 @@ pub(super) fn validate_and_route_for_tests( // `where_clauses` slice for the routing decision, because // the depth-cap and similar decode-time contracts aren't // exercisable otherwise. - conversions::where_clauses_from_proto(request_v1.where_clauses.clone())?; + // + // Time-range (IN_TIME_RANGE) clauses are partitioned out first — + // the same order `query_documents_v1` decodes in. The real handler + // resolves them into bucket equalities from committed block time; + // this stateless helper has no block time (or contract), so it + // only validates the clause shape here. Routing-relevant callers + // pass the resolved equality in their pre-decoded slice. + let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = request_v1 + .where_clauses + .clone() + .into_iter() + .partition(conversions::is_time_range_clause); + for proto_wc in time_range_proto { + conversions::time_range_clause_from_proto(proto_wc)?; + } + conversions::where_clauses_from_proto(normal_proto)?; // 2. ORDER BY decoding — aggregate-target reject as // `Unsupported("ORDER BY on aggregate keys …")`. let order_by_clauses = conversions::order_clauses_from_proto(request_v1.order_by.clone())?; diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 684e13db5e8..a7320f4419e 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -3860,8 +3860,14 @@ mod having_trust_boundary { const TIME_MS: u64 = 1_755_000_000_000; /// Provider that knows exactly one quorum key — the test one. - struct TestQuorumProvider { - pubkey: [u8; 48], + /// + /// This and the two signing helpers below are `pub(super)` so the + /// sibling [`super::time_range_proof_verification`] suite signs its + /// commits through the same canonical construction; a second copy of + /// the tenderdash harness could drift from this one and quietly stop + /// testing the binding. + pub(super) struct TestQuorumProvider { + pub(super) pubkey: [u8; 48], } impl ContextProvider for TestQuorumProvider { @@ -3899,7 +3905,7 @@ mod having_trust_boundary { } /// A deterministic, valid BLS scalar — no RNG dependency. - fn quorum_secret_key() -> SecretKey { + pub(super) fn quorum_secret_key() -> SecretKey { let mut bytes = [0u8; 32]; bytes[31] = 42; SecretKey::::from_be_bytes(&bytes) @@ -4070,7 +4076,7 @@ mod having_trust_boundary { /// Sign a tenderdash precommit whose state id carries `app_hash` — /// the same canonical construction `verify_tenderdash_proof` /// rebuilds on the verify side. - fn signed_proof( + pub(super) fn signed_proof( grovedb_proof: Vec, app_hash: &[u8; 32], mtd: &ResponseMetadata, @@ -4245,3 +4251,570 @@ mod having_trust_boundary { ); } } + +mod time_range_proof_verification { + //! The whole time-range reconstruction sequence, run end to end + //! against a populated Drive: the handler resolves `IN_TIME_RANGE` + //! from committed block time, proves the resulting bucket query, + //! and the client re-derives the *identical* bucket from the + //! quorum-signed response metadata `time_ms`, re-runs the + //! provenance guard, re-picks the bucketed index and verifies the + //! proof over the GroveDB path that selection produces. Every link + //! in that chain is load-bearing: a resolution reading anything + //! but the signed time, a picker admitting the plain index, or a + //! guard accepting a shape the resolver never built would each + //! turn a wrong answer into a *proven* wrong answer. + //! + //! The headline assertion is the overlapping-window one. With + //! `range = 6h, step = 2h` a document is written under three + //! bucket keys at once, so a count that walked buckets rather + //! than addressing exactly one would return three times the + //! truth — and would verify, because the proof would be an + //! honest proof of the wrong query. + //! + //! Split, same as [`super::having_trust_boundary`]: proof + //! end-to-end lives here because generating proofs needs drive's + //! `server` feature and a populated platform; the client-surface + //! half (resolution from metadata time, provenance bookkeeping, + //! ordering) is offline-tested in rs-sdk, which builds drive with + //! `verify` only. + + use super::having_trust_boundary::{quorum_secret_key, signed_proof, TestQuorumProvider}; + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dapi_grpc::platform::v0::get_documents_response::Version as ResponseVersion; + use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; + use dpp::block::block_info::BlockInfo; + use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::document_type::DocumentTypeRef; + use dpp::data_contract::DataContractFactory; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::platform_value::platform_value; + use dpp::prelude::DataContract; + use drive::drive::Drive; + use drive::query::{ + DriveDocumentCountQuery, DriveDocumentQuery, TimeRangeSelector, WhereClause, + }; + use drive_proof_verifier::types::Documents; + use drive_proof_verifier::{ + verify_point_lookup_count_proof, Error as ProofVerifierError, FromProof, SplitCountEntry, + }; + use std::collections::BTreeMap; + + const DOCUMENT_TYPE: &str = "post"; + const CREATED_AT: &str = "$createdAt"; + const BUCKETED_INDEX: &str = "trending"; + const MINUTE_MS: u64 = 60_000; + const HOUR_MS: u64 = 3_600_000; + + /// A bucket start on the contract's grid: `origin` is 0 and the step is + /// two hours, and this is an exact multiple of two hours. Every timestamp + /// below is expressed relative to it. + const NEWEST_BUCKET_START_MS: u64 = 1_755_000_000_000; + /// Committed block time, one hour into the newest bucket — so the newest + /// active range is `NEWEST_BUCKET_START_MS` and there is a full hour of + /// bucket in which documents can sit. + const BLOCK_TIME_MS: u64 = NEWEST_BUCKET_START_MS + HOUR_MS; + const BLOCK_HEIGHT: u64 = 100; + const BLOCK_CORE_HEIGHT: u32 = 42; + const QUORUM_HASH: [u8; 32] = [3u8; 32]; + + /// `($createdAt, hashtag)` for the four posts, chosen so the newest + /// bucket contains: two `#ibiza` posts that *also* live in the two older + /// overlapping buckets (the double-count regression), and one `#berlin` + /// post (so the second index property has to do work). The third + /// `#ibiza` post starts one hour before the newest bucket, so it belongs + /// to the three *older* buckets and to none of the newest — the negative + /// control that pins the query to a single bucket rather than to "recent + /// enough". + const POSTS: [(u64, &str); 4] = [ + (NEWEST_BUCKET_START_MS + 10 * MINUTE_MS, "ibiza"), + (NEWEST_BUCKET_START_MS + 30 * MINUTE_MS, "ibiza"), + (NEWEST_BUCKET_START_MS - HOUR_MS, "ibiza"), + (NEWEST_BUCKET_START_MS + 15 * MINUTE_MS, "berlin"), + ]; + + /// A `countable` bucketed index over `(timeRange($createdAt), hashtag)` + /// with a six-hour window sliding every two hours — overlap factor 3 — + /// next to a plain index covering the same two fields the other way + /// round. The plain one exists so index selection has something wrong to + /// pick: it covers the identical clause field set, and only the + /// resolution provenance keeps the query off it. + fn register_trending_contract( + platform: &Platform, + platform_version: &PlatformVersion, + ) -> DataContract { + let factory = DataContractFactory::new(platform_version.protocol_version) + .expect("expected a factory"); + let schemas = platform_value!({ + DOCUMENT_TYPE: { + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "indices": [ + { + "name": BUCKETED_INDEX, + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "countable": true, + "timeRange": { "on": "$createdAt", "range": 21_600u64, "step": 7_200u64 }, + }, + { + "name": "byHashtag", + "properties": [{ "hashtag": "asc" }, { "$createdAt": "asc" }], + "countable": true, + }, + ], + "required": ["$createdAt", "hashtag"], + "additionalProperties": false, + } + }); + let contract = factory + .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None) + .expect("the trending contract is well-formed") + .data_contract_owned(); + store_data_contract(platform, &contract, platform_version); + contract + } + + fn insert_posts( + platform: &Platform, + contract: &DataContract, + platform_version: &PlatformVersion, + ) -> Vec { + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + POSTS + .iter() + .enumerate() + .map(|(i, (created_at_ms, hashtag))| { + let mut document: Document = document_type + .random_document(Some(7_000 + i as u64), platform_version) + .expect("random document"); + document.set_properties(BTreeMap::from([( + "hashtag".to_string(), + Value::Text(hashtag.to_string()), + )])); + document.set_created_at(Some(*created_at_ms)); + store_document( + platform, + contract, + document_type, + &document, + platform_version, + ); + document + }) + .collect() + } + + fn root_hash(drive: &Drive, platform_version: &PlatformVersion) -> [u8; 32] { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash must be readable") + } + + /// A state whose last committed block carries [`BLOCK_TIME_MS`] — the + /// handler resolves `IN_TIME_RANGE` from it and stamps it into the + /// response metadata, which is what makes the client's re-derivation + /// land on the same bucket. + fn state_with_committed_block_time( + base: &PlatformState, + drive: &Drive, + platform_version: &PlatformVersion, + ) -> PlatformState { + let mut state = base.clone(); + state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: BlockInfo { + time_ms: BLOCK_TIME_MS, + height: BLOCK_HEIGHT, + core_height: BLOCK_CORE_HEIGHT, + epoch: Default::default(), + }, + app_hash: root_hash(drive, platform_version), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + state + } + + /// Contract registered, posts inserted, and a platform state whose + /// committed block time is [`BLOCK_TIME_MS`]. + fn setup_trending( + platform: &TempPlatform, + base_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> (DataContract, Vec, PlatformState) { + assert!( + platform_version.protocol_version >= 14, + "time-range indexes require protocol version 14 or later" + ); + let contract = register_trending_contract(platform, platform_version); + let documents = insert_posts(platform, &contract, platform_version); + let state = state_with_committed_block_time(base_state, &platform.drive, platform_version); + (contract, documents, state) + } + + /// `IN_TIME_RANGE($createdAt, "newest") AND hashtag = ` — the + /// selector rides the wire as an operator, never as a resolved value, so + /// the bucket in the proof can only have come from the node's committed + /// block time. + fn trending_where_clauses(hashtag: &str) -> Vec { + vec![ + wc( + "hashtag", + ProtoWhereOperator::Equal, + Value::Text(hashtag.to_string()), + ), + wc( + CREATED_AT, + ProtoWhereOperator::InTimeRange, + Value::Text(TimeRangeSelector::Newest.as_str().to_string()), + ), + ] + } + + fn trending_request( + contract_id: Vec, + hashtag: &str, + selects: Vec, + ) -> GetDocumentsRequestV1 { + GetDocumentsRequestV1 { + data_contract_id: contract_id, + document_type: DOCUMENT_TYPE.to_string(), + where_clauses: trending_where_clauses(hashtag), + order_by: Vec::new(), + limit: None, + start: None, + prove: true, + selects, + group_by: Vec::new(), + having: Vec::new(), + offset: None, + } + } + + /// Run the request through the real v1 handler and re-sign the proof it + /// produced: the test platform never signs a commit, so the tenderdash + /// binding is supplied here over the *live* root hash and the handler's + /// own response metadata. Tampering with either afterwards is what the + /// negative case does. + fn prove_and_sign( + platform: &TempPlatform, + state: &PlatformState, + request: GetDocumentsRequestV1, + platform_version: &PlatformVersion, + ) -> (Proof, ResponseMetadata, TestQuorumProvider) { + let result = platform + .query_documents_v1(request, state, platform_version) + .expect("query call should not error at the transport layer"); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + let response = result.data.expect("data"); + let proof = match response.result { + Some(get_documents_response_v1::Result::Proof(proof)) => proof, + other => panic!("expected a proof, got {:?}", other), + }; + let mtd = response + .metadata + .expect("the handler stamps response metadata"); + assert_eq!( + mtd.time_ms, BLOCK_TIME_MS, + "the metadata time the client resolves from must be the committed \ + block time the server resolved from" + ); + + let secret_key = quorum_secret_key(); + let signed = signed_proof( + proof.grovedb_proof, + &root_hash(&platform.drive, platform_version), + &mtd, + &secret_key, + QUORUM_HASH, + ); + let provider = TestQuorumProvider { + pubkey: secret_key.public_key().0.to_compressed(), + }; + (signed, mtd, provider) + } + + /// The bucket start the transform puts `time_ms` in, computed straight + /// off the contract's declared window rather than off the constants + /// above — so a fixture edit that moves the grid cannot leave the + /// expectation behind. + fn expected_newest_bucket(contract: &DataContract, time_ms: u64) -> u64 { + contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists") + .indexes() + .get(BUCKETED_INDEX) + .expect("the bucketed index survives contract registration") + .time_range + .as_ref() + .expect("the bucketed index carries its transform") + .newest_active_start(time_ms) + .expect("the block time is inside an active range") + } + + /// The client's reconstruction sequence, step for step, exactly as the + /// SDK's count helper performs it: resolve the selector from the signed + /// metadata time, record the provenance, re-run the shape guard, pick + /// the index that provenance admits, rebuild the prover's count query. + /// + /// Returns the resolved bucket start alongside the query so callers can + /// assert on what the metadata time resolved to. + fn client_count_query<'a>( + contract: &'a DataContract, + document_type: &'a DocumentTypeRef<'a>, + hashtag: &str, + time_ms: u64, + ) -> (DriveDocumentCountQuery<'a>, u64) { + let mut where_clauses = vec![WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(hashtag.to_string()), + }]; + let resolved = drive::query::resolve_time_range_bucket_clause( + CREATED_AT, + TimeRangeSelector::Newest, + *document_type, + time_ms, + ) + .expect("the metadata time falls inside an active range"); + let bucket_start = resolved + .value + .to_integer::() + .expect("a resolved bucket start is a millisecond timestamp"); + where_clauses.push(resolved); + let resolved_fields = vec![CREATED_AT.to_string()]; + + drive::query::validate_resolved_time_range_clause_shapes(&where_clauses, &resolved_fields) + .expect("resolution produces exactly the one equality the guard admits"); + + let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( + document_type.indexes(), + &where_clauses, + &resolved_fields, + ) + .expect("the bucketed index covers the resolved clause set"); + assert_eq!( + index.name, BUCKETED_INDEX, + "resolution provenance must pin selection to the bucketed index, not \ + to the plain index covering the same fields" + ); + + let query = DriveDocumentCountQuery { + document_type: *document_type, + contract_id: contract.id().to_buffer(), + document_type_name: DOCUMENT_TYPE.to_string(), + index, + where_clauses, + }; + (query, bucket_start) + } + + fn total_of(entries: &[SplitCountEntry]) -> u64 { + entries.iter().map(|e| e.count.unwrap_or_default()).sum() + } + + /// The headline case. Two `#ibiza` posts sit in the newest bucket and, + /// because the window overlaps three deep, each is *stored* under three + /// bucket keys. The verified count must be 2 — one per document — which + /// is only true if both sides addressed exactly the one bucket the + /// signed block time names. + #[test] + fn a_count_over_an_overlapping_bucket_verifies_and_counts_each_document_once() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + let transform = document_type + .indexes() + .get(BUCKETED_INDEX) + .expect("the bucketed index survives contract registration") + .time_range + .as_ref() + .expect("the bucketed index carries its transform"); + assert_eq!( + transform.overlap_factor(), + 3, + "the fixture's whole point is that windows overlap" + ); + assert_eq!( + transform.containing_buckets(POSTS[0].0).len(), + 3, + "a matching document must really be stored under three bucket keys" + ); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let client_document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + let (query, bucket_start) = + client_count_query(&contract, &client_document_type, "ibiza", mtd.time_ms); + assert_eq!( + bucket_start, + expected_newest_bucket(&contract, mtd.time_ms), + "the resolved bucket must be the transform's newest active start \ + for the signed time" + ); + assert_eq!(bucket_start, NEWEST_BUCKET_START_MS); + + let entries = verify_point_lookup_count_proof(&query, &proof, &mtd, version, &provider) + .expect("a correctly signed count over the resolved bucket must verify"); + + assert_eq!( + total_of(&entries), + 2, + "the two #ibiza posts in the newest bucket count once each — a \ + document living in three overlapping buckets must not count three \ + times, and the #ibiza post one hour older belongs to the previous \ + buckets only" + ); + } + + /// Move the signed time forward by one full step and the client resolves + /// a *different* bucket. Verification must fail hard: either the proof + /// cannot be replayed over the other bucket's path, or the tenderdash + /// binding rejects the altered metadata. What must never happen is a + /// second, differently-scoped count coming back verified. + #[test] + fn a_tampered_metadata_time_cannot_verify_a_different_bucket() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let step_ms = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists") + .indexes() + .get(BUCKETED_INDEX) + .expect("the bucketed index survives contract registration") + .time_range + .as_ref() + .expect("the bucketed index carries its transform") + .step_ms(); + + let mut tampered = mtd.clone(); + tampered.time_ms += step_ms; + + let client_document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + let (_honest_query, honest_bucket) = + client_count_query(&contract, &client_document_type, "ibiza", mtd.time_ms); + let (tampered_query, tampered_bucket) = + client_count_query(&contract, &client_document_type, "ibiza", tampered.time_ms); + assert_eq!( + tampered_bucket, + honest_bucket + step_ms, + "one step of tampering must move the resolution one bucket — \ + otherwise this test proves nothing about where the bucket comes from" + ); + + let error = + verify_point_lookup_count_proof(&tampered_query, &proof, &tampered, version, &provider) + .expect_err("an altered signed time must not yield a verified count"); + assert!( + matches!( + error, + ProofVerifierError::InvalidSignature { .. } + | ProofVerifierError::GroveDBError { .. } + | ProofVerifierError::DriveError { .. } + ), + "the rejection must be the proof or the signature binding, got: {error:?}" + ); + } + + /// The non-aggregate route through the same fixture: the documents path + /// resolves the selector, carries the provenance onto the drive query + /// and verifies the returned rows. Same bucket scoping, different + /// primitive — so a regression that only reached the documents index + /// picker still turns a test red. + #[test] + fn a_documents_proof_over_the_newest_bucket_verifies_to_the_bucket_members() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, documents, state) = setup_trending(&platform, &base_state, version); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_documents()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + let mut where_clauses = vec![WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("ibiza".to_string()), + }]; + where_clauses.push( + drive::query::resolve_time_range_bucket_clause( + CREATED_AT, + TimeRangeSelector::Newest, + document_type, + mtd.time_ms, + ) + .expect("the metadata time falls inside an active range"), + ); + let mut drive_query = DriveDocumentQuery::from_typed_clauses( + where_clauses, + Vec::new(), + None, + None, + true, + None, + &contract, + document_type, + &platform.config.drive, + version, + ) + .expect("the resolved clause set builds a drive query"); + drive_query.resolved_time_range_fields = vec![CREATED_AT.to_string()]; + + let response = GetDocumentsResponse { + version: Some(ResponseVersion::V1(GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Proof(proof)), + metadata: Some(mtd), + })), + }; + let (verified, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + drive_query, + response, + Network::Testnet, + version, + &provider, + ) + .expect("a correctly signed documents proof must verify"); + + let mut verified_ids: Vec<_> = verified + .expect("the newest bucket is not empty") + .into_iter() + .filter_map(|(id, document)| document.map(|_| id)) + .collect(); + verified_ids.sort(); + let mut expected_ids = vec![documents[0].id(), documents[1].id()]; + expected_ids.sort(); + assert_eq!( + verified_ids, expected_ids, + "only the two #ibiza posts inside the newest bucket are proven \ + members — the older #ibiza post and the #berlin post are not" + ); + } +} diff --git a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs index e6eb7753f3d..f0c74565eea 100644 --- a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs +++ b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs @@ -150,6 +150,7 @@ fn document_query<'a>(case: &Case, contract: &'a DataContract) -> DriveDocumentQ start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], } } diff --git a/packages/rs-drive/benches/document_count_worst_case.rs b/packages/rs-drive/benches/document_count_worst_case.rs index 40e8953bc81..bc96a785c7f 100644 --- a/packages/rs-drive/benches/document_count_worst_case.rs +++ b/packages/rs-drive/benches/document_count_worst_case.rs @@ -1801,6 +1801,7 @@ fn display_proofs(fixture: &CountBenchFixture, platform_version: &PlatformVersio let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &case.structured, + &[], ) .expect("countable picker must find a covering index for the display case"); let query = DriveDocumentCountQuery { @@ -1818,6 +1819,7 @@ fn display_proofs(fixture: &CountBenchFixture, platform_version: &PlatformVersio let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &case.structured, + &[], ) .expect("range_countable picker must find a covering index"); let query = DriveDocumentCountQuery { @@ -2283,6 +2285,7 @@ fn count_request<'a>( limit, prove, drive_config: &fixture.drive_config, + resolved_time_range_fields: vec![], } } diff --git a/packages/rs-drive/benches/document_sum_worst_case.rs b/packages/rs-drive/benches/document_sum_worst_case.rs index 6d42c21dbf8..baf76f6ff1c 100644 --- a/packages/rs-drive/benches/document_sum_worst_case.rs +++ b/packages/rs-drive/benches/document_sum_worst_case.rs @@ -1602,6 +1602,7 @@ fn display_proofs(fixture: &SumBenchFixture, platform_version: &PlatformVersion) document_type, SUM_PROPERTY_NAME, &case.structured, + &[], platform_version, ) .expect("point-lookup path query builds"), @@ -1610,6 +1611,7 @@ fn display_proofs(fixture: &SumBenchFixture, platform_version: &PlatformVersion) document_type, SUM_PROPERTY_NAME, &case.structured, + &[], platform_version, ) .expect("aggregate-range path query builds"), @@ -1621,6 +1623,7 @@ fn display_proofs(fixture: &SumBenchFixture, platform_version: &PlatformVersion) document_type, SUM_PROPERTY_NAME, &case.structured, + &[], limit, left_to_right, platform_version, @@ -1914,6 +1917,7 @@ fn sum_request<'a>( limit, prove, drive_config: &fixture.drive_config, + resolved_time_range_fields: vec![], } } diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs index d81d73c4267..ae2b4eea2f3 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs @@ -317,6 +317,7 @@ fn run( offset: Some(0), has_start_at: false, prove, + resolved_time_range_fields: &[], }, None, platform_version(), diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs index 277da24108c..cff9ee88c41 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs @@ -567,6 +567,7 @@ fn range_count_executor_sums_and_splits_correctly() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("range_countable index should be picked"); @@ -647,6 +648,7 @@ fn range_count_executor_sums_and_splits_correctly() { let after_index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &after_clauses, + &[], ) .expect("range_countable index should be picked"); let after_query = DriveDocumentCountQuery { @@ -747,6 +749,7 @@ fn range_count_executor_between_is_inclusive_on_both_bounds() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("range_countable index should be picked"); @@ -844,6 +847,7 @@ fn aggregate_count_proof_verifies_and_returns_correct_count() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("range_countable index should be picked"); @@ -998,6 +1002,7 @@ fn range_count_with_in_on_prefix_returns_per_brand_color_entries() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("range_countable index should be picked"); @@ -1151,6 +1156,7 @@ fn range_count_executor_accepts_starts_with_in_all_four_modes() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("picker accepts StartsWith"); let query = DriveDocumentCountQuery { @@ -1296,6 +1302,7 @@ fn range_count_executor_accepts_empty_starts_with_prefix_via_sentinel() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("picker accepts StartsWith with any value"); let query = DriveDocumentCountQuery { @@ -1418,6 +1425,7 @@ fn assert_aggregate_count_proof_returns( let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("range_countable index should be picked"); @@ -1715,6 +1723,7 @@ fn aggregate_count_proof_verifies_on_compound_index_with_equal_prefix() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("compound range_countable index should be picked"); @@ -1879,6 +1888,7 @@ fn aggregate_count_proof_counts_cars_in_parking_lots_greater_than_b() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byLot range_countable index should be picked"); let query = DriveDocumentCountQuery { @@ -2135,6 +2145,7 @@ fn range_count_executor_returns_per_lot_counts_for_lots_greater_than_b() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byLot range_countable index should be picked"); @@ -2309,6 +2320,7 @@ fn distinct_count_proof_returns_per_lot_counts_for_lots_greater_than_b() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byLot range_countable index should be picked"); @@ -2654,6 +2666,7 @@ fn distinct_count_proof_honors_request_limit() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byLot picked"); let query = DriveDocumentCountQuery { @@ -2812,6 +2825,7 @@ fn distinct_count_proof_descending_returns_last_limit_keys() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byLot picked"); let query = DriveDocumentCountQuery { @@ -2936,6 +2950,7 @@ fn distinct_count_proof_rejects_limit_above_max_query_limit() { limit: Some(too_large), prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let result = drive.execute_document_count_request(request, None, pv); @@ -3081,6 +3096,7 @@ fn distinct_count_proof_with_in_on_prefix_returns_per_brand_color_entries() { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("byBrandColor picked"); let query = DriveDocumentCountQuery { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs index 390621a9886..f74a9b8e508 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs @@ -795,6 +795,7 @@ fn compound_ranked_index_resolves_its_terminal_level_to_an_indexed_tree() { ranked_countable: false, ranked_summable: false, ranked_averageable: true, + time_range: None, }; let index_structure = IndexLevel::try_from_indices([&compound_ranked_index], "dish", platform_version()) @@ -1010,6 +1011,7 @@ fn a_null_unsearchable_ranked_level_is_what_makes_a_phantom_group_possible() { ranked_countable: true, ranked_summable: false, ranked_averageable: false, + time_range: None, }; for null_searchable in [false, true] { @@ -1505,6 +1507,7 @@ fn ranked_avg_page( offset: Some(offset), has_start_at: false, prove: false, + resolved_time_range_fields: &[], }, None, platform_version(), @@ -1551,6 +1554,7 @@ fn verified_ranked_avg_page( offset: Some(offset), has_start_at: false, prove: true, + resolved_time_range_fields: &[], }, None, platform_version(), diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 884cf5306d5..200a3e86e6c 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -8,12 +8,16 @@ use grovedb::EstimatedSumTrees::NoSumTrees; use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; -use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::index_level_tree_types::{ + index_level_tree_types_with_continuation_demotion, time_range_index_keys, +}; use crate::drive::document::unique_event_id; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use crate::drive::Drive; -use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; +use crate::util::object_size_info::{ + DocumentAndContractInfo, DocumentInfoV0Methods, DriveKeyInfo, PathInfo, +}; use crate::error::fee::FeeError; use crate::error::Error; @@ -155,36 +159,58 @@ impl Drive { let any_fields_null = document_top_field.is_empty(); let all_fields_null = document_top_field.is_empty(); - let mut index_path_info = if document_and_contract_info - .owned_document_info - .document_info - .is_document_size() - { - // This is a stateless operation - PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path)) - } else { - PathInfo::PathAsVec::<0>(index_path) - }; + // Mirror the insert side's time-range fan-out: a time-range + // first-property node removes one index entry per overlapping + // range bucket the document's timestamp fell into. The keys are + // recomputed deterministically through the same shared helper the + // insert walker uses, so they match exactly what insert wrote — + // including the null case (single null entry) and the pre-origin + // case (no entries on either side). + let index_keys: Vec = time_range_index_keys( + sub_level.time_range(), + document_top_field, + // A validated contract cannot exceed this; the clamp only + // bounds estimation work for unvalidated transforms. The + // `unwrap_or(1)` arm is a protocol version without + // time-range indexes, where no transform can exist. + platform_version + .system_limits + .max_time_range_overlap_factor + .unwrap_or(1), + ); - // we push the actual value of the index path - index_path_info.push(document_top_field)?; - // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ - - self.remove_indices_for_index_level_for_contract_operations( - document_and_contract_info, - index_path_info, - sub_level, - any_fields_null, - all_fields_null, - value_tree_type, - &storage_flags, - previous_batch_operations, - estimated_costs_only_with_layer_info, - event_id, - transaction, - batch_operations, - platform_version, - )?; + for index_key in index_keys { + let mut index_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + // This is a stateless operation + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path.clone())) + } else { + PathInfo::PathAsVec::<0>(index_path.clone()) + }; + + // we push the actual value of the index path + index_path_info.push(index_key)?; + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ + + self.remove_indices_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + &storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } } Ok(()) } diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index a5c9303e902..028dd2d78d6 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -75,7 +75,9 @@ use crate::drive::document::ranked_index_tree_type::property_name_tree_type_and_ranked_axes; use crate::error::Error; -use dpp::data_contract::document_type::IndexLevel; +use crate::util::object_size_info::DriveKeyInfo; +use dpp::data_contract::document_type::{IndexLevel, TimeRangeTransform}; +use grovedb::batch::key_info::KeyInfo; use grovedb::element::IndexAxis; use grovedb::TreeType; @@ -128,6 +130,67 @@ pub(crate) fn index_level_tree_types_with_continuation_demotion( }) } +/// Expands a document's raw top-field key into the set of index-entry keys a +/// time-range first-property node stores it under. For a node without a +/// transform the single key passes through untouched. +/// +/// Shared by the insert and delete v2 walkers (same must-not-drift contract +/// as the tree-type derivation above); the entry-key rule itself — null keeps +/// its single null entry, pre-origin timestamps produce no entries, +/// undecodable values keep their raw key — lives in +/// [`TimeRangeTransform::entry_keys_for_raw`], which the update walker also +/// calls. +/// +/// On the estimated-cost path (`KeySize`) the real timestamp isn't available, +/// so this assumes the worst case of `overlap_factor` overlapping buckets — +/// and makes each worst-case key **distinct** by suffixing an ordinal: +/// identical `(path, key)` operations collapse inside grovedb's batch +/// structure, so `overlap` copies of one key would silently estimate a single +/// bucket's cost. +/// +/// `max_overlap_factor` is the platform version's +/// `SystemLimits::max_time_range_overlap_factor` — a validated contract can +/// never exceed it, so the clamp only bounds estimation work for a transform +/// built outside validation, and reading it from the version keeps the +/// estimated fan-out in step with whatever a future protocol version allows. +pub(crate) fn time_range_index_keys<'a>( + transform: Option<&TimeRangeTransform>, + document_top_field: DriveKeyInfo<'a>, + max_overlap_factor: u64, +) -> Vec> { + let Some(transform) = transform else { + return vec![document_top_field]; + }; + match &document_top_field { + DriveKeyInfo::KeySize(key_info) => { + let overlap = transform.overlap_factor().clamp(1, max_overlap_factor) as usize; + (0..overlap) + .map(|ordinal| { + let mut key_info = key_info.clone(); + let suffix = (ordinal as u16).to_be_bytes(); + match &mut key_info { + KeyInfo::KnownKey(bytes) => bytes.extend_from_slice(&suffix), + KeyInfo::MaxKeySize { unique_id, .. } => { + unique_id.extend_from_slice(&suffix) + } + } + DriveKeyInfo::KeySize(key_info) + }) + .collect() + } + DriveKeyInfo::Key(raw) => transform + .entry_keys_for_raw(raw) + .into_iter() + .map(DriveKeyInfo::Key) + .collect(), + DriveKeyInfo::KeyRef(raw) => transform + .entry_keys_for_raw(raw) + .into_iter() + .map(DriveKeyInfo::Key) + .collect(), + } +} + /// Pure derivation of the value-tree type over the level's four /// terminator flags plus whether continuations hang beneath it. Split /// out so the full input space is unit-testable without constructing @@ -329,6 +392,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: true, + time_range: None, }; let compound = Index { name: "byRestaurantChef".to_string(), @@ -352,6 +416,7 @@ mod tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, }; let index_structure = diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs index 7612dab7976..7948156be7f 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs @@ -185,6 +185,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs index 4b5ea32f058..24d182f6d51 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs @@ -4,13 +4,14 @@ use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_d UniquenessOfDataRequestUpdateType, UniquenessOfDataRequestV1, }; use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; +use crate::error::drive::DriveError; use crate::error::Error; use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; use dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; use dpp::consensus::state::state_error::StateError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::document::{property_names, DocumentV0Getters}; -use dpp::platform_value::platform_value; +use dpp::platform_value::{platform_value, Value}; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -70,7 +71,7 @@ impl Drive { // if an index is not unique there is no issue None } else { - let (where_queries, allow_original) = match &update_type { + let (mut where_queries, allow_original) = match &update_type { UniquenessOfDataRequestUpdateType::NewDocument => { let where_queries = index .properties @@ -352,6 +353,71 @@ impl Drive { // there are empty fields, which means that the index is no longer unique None } else { + // A unique time-range index stores bucket *starts* + // under its first property, never raw timestamps, so + // probing it with the candidate document's own + // timestamp would look in a key that no document ever + // occupies and report every duplicate as unique. + // Rewrite the source equality to the containing + // bucket, and record the rewrite as provenance so + // index selection admits the bucketed index (see + // `index_admissible_for_resolved_time_range`): this is + // a legitimate internal producer of a resolved bucket + // equality — the value is derived deterministically + // from the candidate document's own timestamp through + // the contract's transform, so every node computes the + // identical clause. + // + // On the `ChangedDocument` path this stays correct + // without any old-vs-new bucket tracking (which the + // request has no field for — note the arm carries no + // `changed_created_at` flag): a unique time-range + // index is validated to bucket `$createdAt`, which is + // immutable across updates, so the bucket component of + // the tuple never moves and `allow_original` keeps its + // meaning — the tuple changed exactly when one of the + // index's other properties changed. + let mut resolved_time_range_fields = Vec::new(); + if let Some(transform) = &index.time_range { + let Some(clause) = where_queries.get_mut(transform.source.as_str()) + else { + // Unreachable: the transform's source is + // validated to be the index's first property, + // and the count check above established that + // every property produced a clause. + return Some(Err(Error::Drive( + DriveError::CorruptedCodeExecution( + "a time-range index's source must be one of its \ + properties", + ), + ))); + }; + // The clause value was built by `platform_value!` + // from an `Option`, so it is a + // `U64`; `I64` is accepted defensively because a + // non-system-timestamp source would arrive through + // the document data map. Anything else cannot be a + // millisecond timestamp, so there is no bucket to + // probe and the index cannot be violated by it. + let timestamp = match &clause.value { + Value::U64(timestamp) => Some(*timestamp), + Value::I64(timestamp) => u64::try_from(*timestamp).ok(), + _ => None, + }; + let timestamp = timestamp?; + // A validated unique time-range index has overlap + // factor 1 (range == step), so a timestamp at or + // after the transform's origin yields exactly one + // containing bucket. An empty result means the + // timestamp predates the origin: such documents + // produce no index entries at all, so they cannot + // collide with anything under this index and the + // whole check is skipped for it. + let bucket_start = *transform.containing_buckets(timestamp).first()?; + clause.value = platform_value!(bucket_start); + resolved_time_range_fields.push(transform.source.clone()); + } + let query = DriveDocumentQuery { contract, document_type, @@ -368,6 +434,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields, }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 5e1f8f3672d..74d6eb1616c 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -700,3 +700,467 @@ mod tests { ); } } + +#[cfg(test)] +#[cfg(feature = "server")] +mod unique_time_range_index_tests { + //! A unique time-range index expresses "at most one document per + //! non-overlapping window per remaining key tuple" — one report per author + //! per day. Its uniqueness probe therefore has to look in the *bucket* the + //! candidate's `$createdAt` falls into: the index stores bucket starts, not + //! raw timestamps, so a probe on the raw value would look at a key no + //! document ever occupies and pronounce every duplicate unique. + //! + //! The rewritten equality is indistinguishable from a client-written one + //! once built, so the probe must also carry the resolution provenance — + //! without it index selection refuses to route a `$createdAt` equality to a + //! bucketed index and the probe would fail to find any index at all. + use std::borrow::Cow; + use std::collections::{BTreeMap, BTreeSet}; + + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::DataContractFactory; + use dpp::document::{Document, DocumentV0, DocumentV0Getters}; + use dpp::identifier::Identifier; + use dpp::platform_value::{platform_value, Value}; + use dpp::prelude::DataContract; + use dpp::tests::utils::generate_random_identifier_struct; + use dpp::validation::SimpleConsensusValidationResult; + use dpp::version::PlatformVersion; + + use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::{ + UniquenessOfDataRequest, UniquenessOfDataRequestUpdateType, UniquenessOfDataRequestV1, + }; + use crate::drive::Drive; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + + /// One day in each of the two units these tests deal in: the contract + /// declares its window in seconds, while every `$createdAt` and every + /// bucket start is a millisecond timestamp. + const DAY_SECONDS: u64 = 24 * 3_600; + const DAY_MS: u64 = 24 * 3_600_000; + /// Start of the window every "same window" timestamp below lands in, in + /// both units. It is a whole number of days, so it is a bucket start both + /// under the default origin (0) and under an origin of that same window. + const WINDOW_SECONDS: u64 = 100 * DAY_SECONDS; + const WINDOW_MS: u64 = 100 * DAY_MS; + + /// A `report` document type with a UNIQUE + /// `(timeRange($createdAt, range = step = 1 day), author)` index: one + /// report per author per calendar day. Both index properties are required, + /// so neither can be null and the terminator always takes the unique + /// layout. + /// + /// `origin_seconds` shifts the window grid; `None` leaves it at the + /// default (0), where no `u64` timestamp can predate the origin. + fn build_unique_daily_report_contract(origin_seconds: Option) -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let mut time_range = vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(DAY_SECONDS)), + (Value::Text("step".to_string()), Value::U64(DAY_SECONDS)), + ]; + if let Some(origin_seconds) = origin_seconds { + time_range.push(( + Value::Text("origin".to_string()), + Value::U64(origin_seconds), + )); + } + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("dailyReport".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"author": "asc"}), + ]), + ), + (Value::Text("unique".to_string()), Value::Bool(true)), + (Value::Text("timeRange".to_string()), Value::Map(time_range)), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "author": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["author", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "report": document_schema }); + factory + .create_with_value_config(generate_random_identifier_struct(), 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() + } + + fn setup(platform_version: &'static PlatformVersion) -> (Drive, DataContract) { + setup_with_origin(None, platform_version) + } + + fn setup_with_origin( + origin_seconds: Option, + platform_version: &'static PlatformVersion, + ) -> (Drive, DataContract) { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_unique_daily_report_contract(origin_seconds); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + (drive, contract) + } + + /// Stores a `report` and returns its id. + fn insert_report( + drive: &Drive, + contract: &DataContract, + created_at: u64, + author: &str, + platform_version: &PlatformVersion, + ) -> Identifier { + let document_type = contract.document_type_for_name("report").expect("report"); + let owner_bytes = rand::random::<[u8; 32]>(); + let document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("author".to_string(), Value::Text(author.to_string()))]), + created_at: Some(created_at), + revision: Some(1), + ..Default::default() + }); + let document_id = document.id(); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + document_id + } + + /// Runs the uniqueness validation for a candidate `report`. + fn check_uniqueness( + drive: &Drive, + contract: &DataContract, + document_id: Identifier, + created_at: u64, + author: &str, + update_type: UniquenessOfDataRequestUpdateType, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + let document_type = contract.document_type_for_name("report").expect("report"); + let data = BTreeMap::from([("author".to_string(), Value::Text(author.to_string()))]); + let request = UniquenessOfDataRequestV1 { + contract, + document_type, + owner_id: Identifier::from([0x01; 32]), + creator_id: None, + document_id, + created_at: Some(created_at), + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: &data, + update_type, + }; + drive + .validate_uniqueness_of_data( + UniquenessOfDataRequest::V1(request), + None, + platform_version, + ) + .expect("uniqueness validation should run") + } + + fn assert_duplicate(result: &SimpleConsensusValidationResult, context: &str) { + assert!( + matches!( + result.errors.first(), + Some(ConsensusError::StateError( + StateError::DuplicateUniqueIndexError(_) + )) + ), + "{context}: expected a DuplicateUniqueIndexError, got: {:?}", + result.errors + ); + } + + /// The core of the feature: two documents whose raw `$createdAt` values + /// differ by hours still occupy the same day bucket, so the second one + /// violates the unique index. A probe that compared raw timestamps would + /// see an empty index and let it through. + #[test] + fn candidate_in_the_same_window_as_a_stored_document_is_a_duplicate() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + insert_report( + &drive, + &contract, + WINDOW_MS + 3_600_000, + "alice", + platform_version, + ); + + let result = check_uniqueness( + &drive, + &contract, + Identifier::from([0xAA; 32]), + // 8 hours later — a different timestamp, the same day bucket. + WINDOW_MS + 9 * 3_600_000, + "alice", + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ); + assert_duplicate(&result, "same window, same author"); + } + + /// The next window is a different bucket, so the same author may report + /// again — and the write actually goes through, proving the probe agrees + /// with the layout the insert walker builds. + #[test] + fn candidate_in_the_next_window_is_unique_and_insertable() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + insert_report( + &drive, + &contract, + WINDOW_MS + 3_600_000, + "alice", + platform_version, + ); + + let next_window_timestamp = WINDOW_MS + DAY_MS + 3_600_000; + let result = check_uniqueness( + &drive, + &contract, + Identifier::from([0xAA; 32]), + next_window_timestamp, + "alice", + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ); + assert!( + result.is_valid(), + "the next window is a different bucket: {:?}", + result.errors + ); + + insert_report( + &drive, + &contract, + next_window_timestamp, + "alice", + platform_version, + ); + } + + /// The bucket is only the first component of the tuple: a different author + /// in the same window is a different slot. + #[test] + fn candidate_in_the_same_window_with_a_different_suffix_is_unique() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + insert_report( + &drive, + &contract, + WINDOW_MS + 3_600_000, + "alice", + platform_version, + ); + + let result = check_uniqueness( + &drive, + &contract, + Identifier::from([0xAA; 32]), + WINDOW_MS + 9 * 3_600_000, + "bob", + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ); + assert!( + result.is_valid(), + "a different author in the same window is a different tuple: {:?}", + result.errors + ); + + insert_report( + &drive, + &contract, + WINDOW_MS + 9 * 3_600_000, + "bob", + platform_version, + ); + } + + /// Update semantics. `$createdAt` is immutable — that is exactly why a + /// unique time-range index is allowed to bucket it — so on the + /// `ChangedDocument` path the bucket component of the tuple never moves and + /// `allow_original` keeps its ordinary meaning: a document may keep the + /// slot it already holds, but may not move into one another document holds. + #[test] + fn changed_document_keeps_its_own_slot_but_cannot_take_another_documents_slot() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + let alice_created_at = WINDOW_MS + 3_600_000; + let alice_id = insert_report( + &drive, + &contract, + alice_created_at, + "alice", + platform_version, + ); + // A second document in the SAME window under a different author. + insert_report( + &drive, + &contract, + WINDOW_MS + 9 * 3_600_000, + "bob", + platform_version, + ); + + // Alice's document changing its author to "bob" walks into the slot + // bob's document already holds, in the same (unchanged) bucket. + let changed_author: BTreeSet = BTreeSet::from(["author".to_string()]); + let result = check_uniqueness( + &drive, + &contract, + alice_id, + alice_created_at, + "bob", + UniquenessOfDataRequestUpdateType::ChangedDocument { + changed_owner_id: false, + changed_updated_at: false, + changed_transferred_at: false, + changed_updated_at_block_height: false, + changed_transferred_at_block_height: false, + changed_updated_at_core_block_height: false, + changed_transferred_at_core_block_height: false, + changed_data_values: Cow::Borrowed(&changed_author), + }, + platform_version, + ); + assert_duplicate(&result, "moving onto another document's tuple"); + + // The same document re-submitted with its own tuple finds itself in + // the bucket and is allowed to stay (`allow_original`). + let unchanged: BTreeSet = BTreeSet::new(); + let result = check_uniqueness( + &drive, + &contract, + alice_id, + alice_created_at, + "alice", + UniquenessOfDataRequestUpdateType::ChangedDocument { + changed_owner_id: false, + changed_updated_at: false, + changed_transferred_at: false, + changed_updated_at_block_height: false, + changed_transferred_at_block_height: false, + changed_updated_at_core_block_height: false, + changed_transferred_at_core_block_height: false, + changed_data_values: Cow::Borrowed(&unchanged), + }, + platform_version, + ); + assert!( + result.is_valid(), + "a document must be allowed to keep the slot it already holds: {:?}", + result.errors + ); + } + + /// A `$createdAt` predating the transform's origin produces no index + /// entries at all (the insert walker writes none), so such a document + /// cannot collide with anything: the probe must skip this index entirely + /// rather than invent a bucket for a timestamp that belongs to none. + #[test] + fn pre_origin_candidate_skips_the_time_range_index_check() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup_with_origin(Some(WINDOW_SECONDS), platform_version); + + // A stored report in the transform's first window. + insert_report( + &drive, + &contract, + WINDOW_MS + 3_600_000, + "alice", + platform_version, + ); + + // Same author, timestamp one millisecond before the origin: it belongs + // to no window, so there is nothing for it to duplicate. + let result = check_uniqueness( + &drive, + &contract, + Identifier::from([0xAA; 32]), + WINDOW_MS - 1, + "alice", + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ); + assert!( + result.is_valid(), + "a pre-origin document is not indexed, so it cannot collide: {:?}", + result.errors + ); + + // Contrast: inside the first window the same author does collide, so + // the skip above is the origin talking and not a probe that silently + // stopped working on this contract. + let result = check_uniqueness( + &drive, + &contract, + Identifier::from([0xAA; 32]), + WINDOW_MS + 9 * 3_600_000, + "alice", + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ); + assert_duplicate(&result, "inside the first window"); + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index df06944d777..db1b8ae170d 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -62,3 +62,1051 @@ impl Drive { } } } + +#[cfg(test)] +mod time_range_index_e2e_tests { + //! End-to-end coverage for time-range index fan-out: a single document is + //! indexed under every overlapping range bucket its `$createdAt` falls + //! into, those buckets are queryable by exact bucket start, and deletion + //! removes every entry. + //! + //! Also covers the other half of that contract: a bucket-start equality + //! only means "bucket" when it came from `IN_TIME_RANGE` resolution, so + //! index selection is pinned by + //! [`DriveDocumentQuery::resolved_time_range_fields`] rather than left to + //! whichever index happens to cover the fields. + use crate::config::DriveConfig; + use crate::drive::Drive; + use crate::query::DriveDocumentQuery; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::DataContractFactory; + use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; + use dpp::platform_value::{platform_value, Identifier, Value}; + use dpp::prelude::DataContract; + use dpp::tests::utils::generate_random_identifier_struct; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// One hour in each of the two units these tests deal in: `*_SECONDS` + /// declares a contract's window, `*_MS` is a document timestamp, a bucket + /// start or an index key. Scaling the wrong one silently shifts the + /// buckets by a factor of a thousand, so they are kept apart by name. + const HOUR_SECONDS: u64 = 3_600; + const HOUR_MS: u64 = 3_600_000; + + /// A latest-protocol `post` document type with a `(timeRange($createdAt, range=6h, + /// step=2h), hashtag)` countable index — i.e. trending hashtags over a + /// 6-hour window refreshed every 2 hours (overlap factor 3). + fn build_trending_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("trending".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(2 * HOUR_SECONDS), + ), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = generate_random_identifier_struct(); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() + } + + /// Number of documents the `trending` index returns for an exact + /// `$createdAt == bucket` lookup. + /// + /// The equality is what `IN_TIME_RANGE` resolution produces, so the query + /// is marked as such: without that provenance index selection refuses to + /// bind a bare `$createdAt` equality to a bucketed index, exactly as it + /// refuses a client-written one. + fn count_in_bucket( + drive: &Drive, + contract: &DataContract, + bucket: u64, + platform_version: &PlatformVersion, + ) -> usize { + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query( + contract, + document_type, + bucket, + None, + vec!["$createdAt".to_string()], + ); + query + .execute_raw_results_no_proof(drive, None, None, platform_version) + .expect("query") + .0 + .len() + } + + /// A `$createdAt == created_at` query, optionally ANDed with + /// `hashtag == `, carrying `resolved_time_range_fields` verbatim + /// so tests can drive both the resolved and the raw (empty) provenance. + fn build_created_at_query<'a>( + contract: &'a DataContract, + document_type: dpp::data_contract::document_type::DocumentTypeRef<'a>, + created_at: u64, + hashtag: Option<&str>, + resolved_time_range_fields: Vec, + ) -> DriveDocumentQuery<'a> { + let mut clauses = vec![Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(created_at), + ])]; + if let Some(hashtag) = hashtag { + clauses.push(Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("==".to_string()), + Value::Text(hashtag.to_string()), + ])); + } + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(clauses), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + query.resolved_time_range_fields = resolved_time_range_fields; + query + } + + #[test] + fn time_range_insert_fans_out_to_overlapping_buckets_and_delete_removes_them() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + let transform = document_type + .indexes() + .get("trending") + .expect("trending index") + .time_range + .clone() + .expect("time range transform"); + assert_eq!(transform.overlap_factor(), 3); + + // A document created at 7h+ falls into the ranges starting at 6h, 4h, 2h. + let created_at = 7 * HOUR_MS + 123_456; + let expected_buckets = transform.containing_buckets(created_at); + assert_eq!( + expected_buckets, + vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS] + ); + + let owner_bytes = rand::random::<[u8; 32]>(); + let document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + let document_id = document.id(); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + // The document is queryable under each of its 3 overlapping buckets. + for bucket in &expected_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "document should be indexed under bucket {bucket}" + ); + } + // It is NOT stored under the raw timestamp (only under bucket starts)… + assert_eq!( + count_in_bucket(&drive, &contract, created_at, platform_version), + 0, + "document must be indexed under bucket starts, not the raw timestamp" + ); + // …nor under a range that does not contain it. + assert_eq!( + count_in_bucket(&drive, &contract, 0, platform_version), + 0, + "an unrelated bucket must be empty" + ); + + // Deleting the document removes every bucket entry. + drive + .delete_document_for_contract( + document_id, + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + + for bucket in &expected_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "bucket {bucket} should be empty after deletion" + ); + } + } + + /// Update-path set-diff coverage: moving a timestamp between bucket sets + /// must delete the stale entries and insert the new ones, and a + /// sub-property change at an unchanged timestamp must reinsert under the + /// new suffix without duplicating entries. (Null transitions are + /// unreachable through a valid contract: the transform's system-timestamp + /// source must be a required field, so documents always carry it; the + /// walkers' null-entry handling is defense-in-depth covered by + /// `TimeRangeTransform::entry_keys_for_raw`'s unit tests.) + #[test] + fn time_range_update_moves_between_buckets_and_suffix_changes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + let transform = document_type + .indexes() + .get("trending") + .expect("trending index") + .time_range + .clone() + .expect("time range transform"); + + let first_created_at = 7 * HOUR_MS + 123_456; + let first_buckets = transform.containing_buckets(first_created_at); + assert_eq!(first_buckets, vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS]); + let second_created_at = 13 * HOUR_MS + 42; + let second_buckets = transform.containing_buckets(second_created_at); + assert_eq!( + second_buckets, + vec![12 * HOUR_MS, 10 * HOUR_MS, 8 * HOUR_MS] + ); + + let owner_bytes = rand::random::<[u8; 32]>(); + let mut document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(first_created_at), + revision: Some(1), + ..Default::default() + }); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + let update = |document: &Document, step: &str| { + drive + .update_document_for_contract( + document, + &contract, + document_type, + Some(owner_bytes), + BlockInfo::default(), + true, + None, + None, + platform_version, + None, + ) + .unwrap_or_else(|e| panic!("update document ({step}): {e:?}")); + }; + + // Move the timestamp to a disjoint bucket set: the stale entries must + // be deleted and the new ones inserted. + document.set_created_at(Some(second_created_at)); + document.set_revision(Some(2)); + update(&document, "move buckets"); + for bucket in &first_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "old bucket {bucket} should be empty after the timestamp moved" + ); + } + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "new bucket {bucket} should hold the document after the timestamp moved" + ); + } + + // A sub-property change at an unchanged timestamp reinserts under the + // new suffix without duplicating bucket entries. + document.set("hashtag", Value::Text("mykonos".to_string())); + document.set_revision(Some(3)); + update(&document, "suffix change"); + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "bucket {bucket} should still hold exactly one entry after a suffix change" + ); + } + + // Deleting the document (now holding bucket entries created by the + // update path) removes every entry. + drive + .delete_document_for_contract( + document.id(), + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "bucket {bucket} should be empty after deletion" + ); + } + } + + /// The `post` type of [`build_trending_contract`] plus two plain indexes + /// over the same fields, storing raw timestamps: + /// + /// - `byHashtag` — `(hashtag, $createdAt)`. Covers exactly the fields the + /// bucketed `trending` index covers, and sorts before it, so a search + /// that only scores field coverage — ties broken by the index map's name + /// order — always prefers it. Which of the two is correct depends + /// entirely on where the `$createdAt` value came from. + /// - `byHashtagAndAuthor` — `(hashtag, $createdAt, author)`. The only + /// index that can also serve an ordering by `author`, so it is what an + /// unpinned search falls back to for a time-range query that orders by + /// a property the bucketed index does not carry. + /// + /// Both plain indexes start with `hashtag` rather than `$createdAt`: + /// indexes sharing a first property must agree on its `timeRange` + /// transform, so a raw index can never lead with a bucketed field. + fn build_competing_index_trending_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let trending_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("trending".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(2 * HOUR_SECONDS), + ), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]; + let by_hashtag_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byHashtag".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"hashtag": "asc"}), + platform_value!({"$createdAt": "asc"}), + ]), + ), + ]; + let by_hashtag_and_author_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byHashtagAndAuthor".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"hashtag": "asc"}), + platform_value!({"$createdAt": "asc"}), + platform_value!({"author": "asc"}), + ]), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + "author": {"type": "string", "maxLength": 63, "position": 1}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![ + Value::Map(by_hashtag_index), + Value::Map(by_hashtag_and_author_index), + Value::Map(trending_index), + ]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = generate_random_identifier_struct(); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() + } + + /// Stores one `post` with the given timestamp and hashtag. + fn insert_post( + drive: &Drive, + contract: &DataContract, + created_at: u64, + hashtag: &str, + platform_version: &PlatformVersion, + ) { + let document_type = contract.document_type_for_name("post").expect("post"); + let owner_bytes = rand::random::<[u8; 32]>(); + let document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text(hashtag.to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + } + + /// With two indexes covering the same fields, the value's provenance — + /// not the index map's name order — decides which index serves the query: + /// a resolved bucket start goes to the bucketed index, a raw timestamp to + /// the plain one. Getting this wrong returns a validly-proven empty result + /// in either direction, so both halves are asserted. + #[test] + fn resolved_time_range_equality_pins_the_bucketed_index_while_a_raw_one_uses_the_plain_index() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_competing_index_trending_contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + assert_eq!( + document_type.indexes().keys().next().map(String::as_str), + Some("byHashtag"), + "the plain index must sort first for this to reproduce the tie-break the \ + pinning fixes" + ); + + let created_at = 7 * HOUR_MS + 123_456; + let bucket = 6 * HOUR_MS; + insert_post(&drive, &contract, created_at, "ibiza", platform_version); + // A second post in the same bucket under a different hashtag: the + // hashtag equality must still narrow the result to one document. + insert_post(&drive, &contract, created_at, "mykonos", platform_version); + + let resolved = build_created_at_query( + &contract, + document_type, + bucket, + Some("ibiza"), + vec!["$createdAt".to_string()], + ); + assert_eq!( + resolved + .find_best_index(platform_version) + .expect("the bucketed index covers the resolved query") + .name, + "trending" + ); + assert_eq!( + resolved + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("resolved query executes") + .0 + .len(), + 1, + "the resolved bucket equality must find the document stored under that bucket" + ); + + let raw = + build_created_at_query(&contract, document_type, created_at, Some("ibiza"), vec![]); + assert_eq!( + raw.find_best_index(platform_version) + .expect("the plain index covers the raw query") + .name, + "byHashtag", + "a raw `$createdAt` equality must never bind to bucket keys" + ); + assert_eq!( + raw.execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("raw query executes") + .0 + .len(), + 1, + "the plain index stores raw timestamps, so the raw equality finds the document" + ); + + // The converse of the raw case: a bucket start is not a timestamp any + // document carries, so the plain index legitimately matches nothing. + let raw_on_bucket = + build_created_at_query(&contract, document_type, bucket, Some("ibiza"), vec![]); + assert_eq!( + raw_on_bucket + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("raw query executes") + .0 + .len(), + 0 + ); + } + + /// One index can bucket only one field (a transform's source must be its + /// index's first property), so a query resolving two time ranges has no + /// servable shape and is refused rather than routed to whichever index + /// happens to cover the fields. + #[test] + fn two_resolved_time_range_fields_are_rejected() { + let contract = build_competing_index_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query( + &contract, + document_type, + 6 * HOUR_MS, + Some("ibiza"), + vec!["$createdAt".to_string(), "hashtag".to_string()], + ); + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("two resolved time-range fields cannot be served"); + assert!( + matches!( + error, + crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) + ), + "expected an Unsupported rejection, got {error:?}" + ); + } + + /// Ordering by a property the bucketed index does not carry must fail + /// loudly. `byHashtagAndAuthor` covers the same where fields *and* the + /// ordering, so an unpinned search would take it and match the bucket + /// start against raw timestamps — a validly-proven empty result. + #[test] + fn resolved_time_range_query_ordering_off_the_bucketed_index_errors_rather_than_falling_back() { + let contract = build_competing_index_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query_value = Value::Map(vec![ + ( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("==".to_string()), + Value::Text("ibiza".to_string()), + ]), + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(6 * HOUR_MS), + ]), + ]), + ), + ( + Value::Text("orderBy".to_string()), + Value::Array(vec![Value::Array(vec![ + Value::Text("author".to_string()), + Value::Text("asc".to_string()), + ])]), + ), + ]); + let mut query = DriveDocumentQuery::from_value( + query_value, + &contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + // Sanity: without the provenance this query has a covering index, so + // the rejection below is the pinning talking and not a query that + // nothing could serve. + assert_eq!( + query + .find_best_index(PlatformVersion::latest()) + .expect("a plain index covers the where fields and the ordering") + .name, + "byHashtagAndAuthor" + ); + + query.resolved_time_range_fields = vec!["$createdAt".to_string()]; + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("no bucketed index covers the ordering"); + assert!( + matches!( + error, + crate::error::Error::Query( + crate::error::query::QuerySyntaxError::WhereClauseOnNonIndexedProperty(_) + ) + ), + "expected a no-covering-index rejection, got {error:?}" + ); + } + + const DAY_SECONDS: u64 = 24 * HOUR_SECONDS; + const DAY_MS: u64 = 24 * HOUR_MS; + + /// A `report` document type with a **unique** + /// `(timeRange($createdAt, range = step = 1 day), author)` index — one + /// report per author per calendar day. + /// + /// `range == step` makes the windows a partition (overlap factor 1), which + /// is what lets uniqueness mean anything here, and `$createdAt` is + /// immutable so a document's bucket never moves. Both index properties are + /// required, so the terminator always takes the unique layout (the + /// reference stored AT `[0]`, with no per-document subtree). + fn build_unique_daily_report_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("dailyReport".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"author": "asc"}), + ]), + ), + (Value::Text("unique".to_string()), Value::Bool(true)), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(DAY_SECONDS)), + (Value::Text("step".to_string()), Value::U64(DAY_SECONDS)), + ]), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "author": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["author", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "report": document_schema }); + let owner_id = generate_random_identifier_struct(); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() + } + + /// Number of `report`s stored under the exact `(bucket, author)` tuple of + /// the unique index. Carries the `IN_TIME_RANGE` provenance for the same + /// reason [`count_in_bucket`] does. + fn count_reports_for( + drive: &Drive, + contract: &DataContract, + bucket: u64, + author: &str, + platform_version: &PlatformVersion, + ) -> usize { + let document_type = contract.document_type_for_name("report").expect("report"); + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(bucket), + ]), + Value::Array(vec![ + Value::Text("author".to_string()), + Value::Text("==".to_string()), + Value::Text(author.to_string()), + ]), + ]), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + query.resolved_time_range_fields = vec!["$createdAt".to_string()]; + query + .execute_raw_results_no_proof(drive, None, None, platform_version) + .expect("query") + .0 + .len() + } + + /// A suffix change under a **unique** bucketed index exercises the update + /// walker's unique terminator layout end to end: the old `(bucket, author)` + /// slot must be vacated and the new one occupied. Under the non-unique + /// layout the walker would delete a doc-id key that does not exist and + /// write the reference one level too deep, leaving the old entry in place + /// and the new one unfindable. + #[test] + fn unique_time_range_index_update_moves_the_entry_between_suffixes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_unique_daily_report_contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("report").expect("report"); + let index = document_type + .indexes() + .get("dailyReport") + .expect("dailyReport index"); + assert!(index.unique, "the index under test must be unique"); + let transform = index + .time_range + .clone() + .expect("dailyReport buckets $createdAt"); + assert_eq!(transform.overlap_factor(), 1); + + let created_at = 100 * DAY_MS + 3 * HOUR_MS; + let bucket = *transform + .containing_buckets(created_at) + .first() + .expect("a post-origin timestamp has exactly one bucket"); + assert_eq!(bucket, 100 * DAY_MS); + + let owner_bytes = rand::random::<[u8; 32]>(); + let mut document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), + created_at: Some(created_at), + revision: Some(1), + ..Default::default() + }); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + // The insert walker's unique terminator is readable through the + // bucketed index. + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 1, + "the inserted report must be found under its (bucket, author) tuple" + ); + + // Change the suffix. `$createdAt` is untouched — it cannot change — + // so the bucket stays and only the author component of the tuple moves. + document.set("author", Value::Text("bob".to_string())); + document.set_revision(Some(2)); + drive + .update_document_for_contract( + &document, + &contract, + document_type, + Some(owner_bytes), + BlockInfo::default(), + true, + None, + None, + platform_version, + None, + ) + .expect("update document"); + + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 0, + "the old (bucket, author) slot must be vacated by the update" + ); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "bob", platform_version), + 1, + "the new (bucket, author) slot must hold the document after the update" + ); + + // The vacated slot is genuinely free again: a second document may take + // it, which only holds if the update actually removed the reference + // rather than leaving a stale one behind. + let second_owner = rand::random::<[u8; 32]>(); + let second = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(second_owner), + properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), + created_at: Some(created_at + HOUR_MS), + revision: Some(1), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &second, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(second_owner), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add second document into the vacated slot"); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 1 + ); + + // Deleting the updated document clears its slot too — the delete + // walker and the update walker must agree on where the reference is. + drive + .delete_document_for_contract( + document.id(), + &contract, + "report", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "bob", platform_version), + 0, + "the updated document's slot must be empty after deletion" + ); + } + + /// The mirror case: when every index covering the query buckets the + /// field, a raw query has nowhere to go and must be refused instead of + /// silently matching a timestamp against bucket starts. + #[test] + fn raw_query_on_a_doctype_whose_only_covering_index_is_bucketed_errors() { + let contract = build_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query( + &contract, + document_type, + 7 * HOUR_MS + 123_456, + None, + vec![], + ); + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("a raw equality cannot be served by a bucketed index"); + assert!( + matches!( + error, + crate::error::Error::Query( + crate::error::query::QuerySyntaxError::WhereClauseOnNonIndexedProperty(_) + ) + ), + "expected a no-covering-index rejection, got {error:?}" + ); + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs index f7f465faddc..125daccd081 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -4,7 +4,9 @@ use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::drive::Drive; -use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; +use crate::util::object_size_info::{ + DocumentAndContractInfo, DocumentInfoV0Methods, DriveKeyInfo, PathInfo, +}; use crate::error::fee::FeeError; use crate::error::Error; @@ -16,7 +18,9 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::version::PlatformVersion; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; -use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::index_level_tree_types::{ + index_level_tree_types_with_continuation_demotion, time_range_index_keys, +}; use crate::drive::document::paths::contract_document_type_path_vec; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -129,12 +133,11 @@ impl Drive { )? .unwrap_or_default(); - // The zero will not matter here, because the PathKeyInfo is variable - let path_key_info = document_top_field.clone().add_path::<0>(index_path.clone()); // here we are inserting the value tree (per distinct property value) // under the top-level property-name tree. The top-level property-name // tree itself is created at contract setup, so the apply_type's // `in_tree_type` reflects whichever variant the contract setup used. + // Same for every bucket key when this is a time-range node. let value_apply_type = if estimated_costs_only_with_layer_info.is_none() { BatchInsertTreeApplyType::StatefulBatchInsertTree } else { @@ -146,16 +149,6 @@ impl Drive { .unwrap_or_default(), } }; - self.batch_insert_empty_tree_if_not_exists( - path_key_info.clone(), - value_tree_type, - storage_flags, - value_apply_type, - transaction, - previous_batch_operations, - batch_operations, - drive_version, - )?; if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { @@ -192,40 +185,76 @@ impl Drive { let any_fields_null = document_top_field.is_empty(); let all_fields_null = document_top_field.is_empty(); - let mut index_path_info = if document_and_contract_info - .owned_document_info - .document_info - .is_document_size() - { - // This is a stateless operation - PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path)) - } else { - PathInfo::PathAsVec::<0>(index_path) - }; + // A time-range first-property node expands the document's single + // timestamp into one index entry per overlapping range bucket (the + // bucket *start*, encoded exactly like the timestamp). A normal + // property keeps its single key. The entry-key rule (null keeps + // its single null entry, pre-origin timestamps produce no entries, + // undecodable values keep their raw key) lives in ONE place — + // [`TimeRangeTransform::entry_keys_for_raw`] — shared with the + // delete and update walkers so the three can never disagree. + let index_keys: Vec = time_range_index_keys( + sub_level.time_range(), + document_top_field, + // A validated contract cannot exceed this; the clamp only + // bounds estimation work for unvalidated transforms. The + // `unwrap_or(1)` arm is a protocol version without + // time-range indexes, where no transform can exist. + platform_version + .system_limits + .max_time_range_overlap_factor + .unwrap_or(1), + ); + + for index_key in index_keys { + // The zero will not matter here, because the PathKeyInfo is variable + let path_key_info = index_key.clone().add_path::<0>(index_path.clone()); + self.batch_insert_empty_tree_if_not_exists( + path_key_info.clone(), + value_tree_type, + storage_flags, + value_apply_type, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; - // we push the actual value of the index path - index_path_info.push(document_top_field)?; - // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ - - // Propagate the exact (post-demotion) `value_tree_type` we - // just inserted forward as the recursive level's - // `parent_value_tree_type` so its continuation children pick - // the right zero-contribution op. - self.add_indices_for_index_level_for_contract_operations( - document_and_contract_info, - index_path_info, - sub_level, - any_fields_null, - all_fields_null, - value_tree_type, - previous_batch_operations, - &storage_flags, - estimated_costs_only_with_layer_info, - event_id, - transaction, - batch_operations, - platform_version, - )?; + let mut index_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + // This is a stateless operation + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path.clone())) + } else { + PathInfo::PathAsVec::<0>(index_path.clone()) + }; + + // we push the actual value of the index path + index_path_info.push(index_key)?; + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ + + // Propagate the exact (post-demotion) `value_tree_type` we + // just inserted forward as the recursive level's + // `parent_value_tree_type` so its continuation children pick + // the right zero-contribution op. + self.add_indices_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + previous_batch_operations, + &storage_flags, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } } Ok(()) } diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index c245802038e..ae1c0a43b12 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -1,5 +1,7 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; -use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::index_level_tree_types::{ + index_level_tree_types_with_continuation_demotion, IndexLevelTreeTypes, +}; use crate::drive::document::{ make_document_reference, make_document_reference_with_sum_item, read_document_sum_contribution, }; @@ -16,7 +18,7 @@ use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; use crate::util::object_size_info::DriveKeyInfo::{Key, KeyRef, KeySize}; use crate::util::object_size_info::PathKeyElementInfo::PathKeyRefElement; use crate::util::object_size_info::{ - DocumentAndContractInfo, DocumentInfoV0Methods, DriveKeyInfo, PathKeyInfo, + DocumentAndContractInfo, DocumentInfo, DocumentInfoV0Methods, DriveKeyInfo, PathKeyInfo, }; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; @@ -33,7 +35,9 @@ use crate::drive::document::paths::{ contract_documents_primary_key_path, }; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; -use dpp::data_contract::document_type::IndexCountability; +use dpp::data_contract::document_type::{ + DocumentTypeRef, Index, IndexCountability, IndexLevel, TimeRangeTransform, +}; use dpp::version::PlatformVersion; use grovedb::batch::key_info::KeyInfo; use grovedb::batch::key_info::KeyInfo::KnownKey; @@ -357,6 +361,38 @@ impl Drive { document_reference.clone() }; + // Time-range indexes store one entry per overlapping range bucket, + // so they need a set-diff update rather than the single old→new + // value transition below. `current_index_level` is still the + // top-level (source) node and `index_path` is the base + // (…//) at this point. The transform is + // read off the merged `IndexLevel` node — the same source the + // insert and delete walkers branch on — so all three walkers + // agree even on a document type constructed outside full + // validation. (`IndexLevel::try_from_indices` rejects indices + // that share a first property but disagree on the transform, so + // the node's transform is every sharing index's transform.) + if let Some(transform) = current_index_level.time_range() { + self.update_time_range_index_for_contract_operations_v1( + index, + transform, + document, + &old_document_info, + owner_id, + document_type, + &index_path, + current_index_level, + &index_document_reference, + storage_flags, + &mut batch_insertion_cache, + previous_batch_operations, + &mut batch_operations, + transaction, + platform_version, + )?; + continue; + } + // with the example of the dashpay contract's first index // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId let document_top_field = document @@ -809,4 +845,400 @@ impl Drive { } Ok(batch_operations) } + + /// Updates the index entries for a single **time-range** index. + /// + /// A time-range index stores one entry per overlapping range bucket the + /// document's timestamp falls into, so an update is a set diff rather than + /// the single old→new transition the normal-index path performs: + /// - entry keys present only in the old timestamp's set are removed, + /// - entry keys present only in the new timestamp's set are inserted, + /// - keys present in both are refreshed, or (when a later index property + /// changed) deleted under the old sub-values and reinserted under the new + /// ones. + /// + /// The entry-key set for a document mirrors the insert walker exactly: a + /// decodable timestamp yields one key per containing bucket (none when the + /// timestamp predates the transform's origin), a null timestamp yields the + /// single ordinary null key, and a non-null value that fails to decode + /// yields its raw key — so null→value, value→null and null→null + /// transitions maintain the same entries insert and delete would. + /// + /// Time-range indexes are validated to be non-contested, but they may be + /// unique when the windows do not overlap (`range == step`) and the source + /// is `$createdAt`, so both terminator layouts occur here and are chosen + /// exactly as the insert and delete walkers choose them + /// (`!unique || any_fields_null`): the non-unique layout stores the + /// reference under `…//[0]/`, the unique layout stores it + /// AT `…//[0]`. The predicate is evaluated separately for the new + /// and the old document, because the layout an entry was *written* under + /// is the layout it must be *deleted* under. + /// + /// (With a `$createdAt` source and overlap factor 1 the bucket set can + /// never change on an update — the timestamp is immutable and yields + /// exactly one bucket — so on a unique index only the suffix-change arms + /// below are reachable. The layout dispatch is nonetheless applied to + /// every arm rather than reasoned away per arm: the arms are shared with + /// non-unique indexes, and a future relaxation of the uniqueness rules + /// must not silently resurrect the wrong layout.) + /// + /// The estimated-cost / document-size update path never reaches this + /// method — it delegates to the insert path, which has its own bucket + /// fan-out. + #[allow(clippy::too_many_arguments)] + fn update_time_range_index_for_contract_operations_v1( + &self, + index: &Index, + transform: &TimeRangeTransform, + document: &Document, + old_document_info: &DocumentInfo, + owner_id: Option<[u8; 32]>, + document_type: DocumentTypeRef, + base_index_path: &[Vec], + top_index_level: &IndexLevel, + index_document_reference: &Element, + storage_flags: Option<&StorageFlags>, + batch_insertion_cache: &mut HashSet>>, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let drive_version = &platform_version.drive; + + // New/old raw values for the bucketed source property → entry key + // sets, mirroring the insert walker's fan-out (see the doc comment). + let new_raw = document.get_raw_for_document_type( + &transform.source, + document_type, + owner_id, + platform_version, + )?; + let old_raw = match old_document_info.get_raw_for_document_type( + &transform.source, + document_type, + None, // We want to use the old owner id + None, + platform_version, + )? { + Some(Key(k)) => Some(k), + Some(KeyRef(k)) => Some(k.to_vec()), + _ => None, + }; + + // The entry-key rule (null → single null entry, pre-origin → no + // entries, undecodable → raw key, decodable → containing buckets) is + // shared with the insert and delete walkers via + // `TimeRangeTransform::entry_keys_for_raw` — one definition, so the + // three walkers can never disagree. + let new_entry_keys = transform.entry_keys_for_raw(new_raw.as_deref().unwrap_or_default()); + let old_entry_keys = transform.entry_keys_for_raw(old_raw.as_deref().unwrap_or_default()); + + // Terminator-layout inputs, tracked separately for the new and the old + // document and accumulated over the suffix properties in the loop + // below. The insert and delete walkers demote a unique index to the + // non-unique layout as soon as ANY indexed field is null + // (`any_fields_null`, accumulated with OR down the level recursion), so + // an entry's layout is a property of the document that wrote it — the + // delete below must therefore use the OLD document's verdict, not the + // new one's. A null source raw value yields the single empty entry key, + // which is exactly the null case here. + let mut new_any_fields_null = new_raw.as_deref().unwrap_or_default().is_empty(); + let mut old_any_fields_null = old_raw.as_deref().unwrap_or_default().is_empty(); + + // Sub-property suffix (positions 1..) interleaved as [name, value, …] + // for both the new and old document, plus the IndexLevel node at each + // depth with its (pure, hoisted) tree-type derivation — the bucket + // loop below runs up to `overlap_factor` times and must not re-derive + // per iteration. + let mut levels: Vec<(&IndexLevel, IndexLevelTreeTypes)> = Vec::new(); + let mut new_suffix: Vec> = Vec::new(); + let mut old_suffix: Vec> = Vec::new(); + let mut current_level = top_index_level; + for index_property in index.properties.iter().skip(1) { + current_level = + current_level + .sub_levels() + .get(&index_property.name) + .ok_or(Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index structure missing sub_level '{}' under time-range index '{}'", + index_property.name, index.name + ))))?; + levels.push(( + current_level, + index_level_tree_types_with_continuation_demotion(current_level)?, + )); + + let new_val = document + .get_raw_for_document_type( + &index_property.name, + document_type, + owner_id, + platform_version, + )? + .unwrap_or_default(); + let old_val = match old_document_info.get_raw_for_document_type( + &index_property.name, + document_type, + None, + None, + platform_version, + )? { + Some(Key(k)) => k, + Some(KeyRef(k)) => k.to_vec(), + _ => Vec::new(), + }; + new_any_fields_null |= new_val.is_empty(); + old_any_fields_null |= old_val.is_empty(); + + new_suffix.push(index_property.name.as_bytes().to_vec()); + new_suffix.push(new_val); + old_suffix.push(index_property.name.as_bytes().to_vec()); + old_suffix.push(old_val); + } + + // The two terminator layouts, mirroring + // `add_reference_for_index_level_for_contract_operations_v0`'s + // `!index_type.index_type.is_unique() || any_fields_null` dispatch. + let new_terminator_is_unique = index.unique && !new_any_fields_null; + let old_terminator_is_unique = index.unique && !old_any_fields_null; + + let suffix_changed = new_suffix != old_suffix; + let reference_tree_type = + reference_tree_type_for_index(index.countable, &index.summable, index.range_summable); + let top_level_tree_types = + index_level_tree_types_with_continuation_demotion(top_index_level)?; + let top_value_tree_type = top_level_tree_types.value_tree_type; + let doc_id = document.id(); + + let new_set: HashSet<&Vec> = new_entry_keys.iter().collect(); + let old_set: HashSet<&Vec> = old_entry_keys.iter().collect(); + + // Insert/refresh new entries FIRST (see the ordering note on the + // delete loop below): added keys are inserted; common keys are + // refreshed when unchanged or reinserted when the suffix changed. + for entry_key in &new_entry_keys { + if old_set.contains(entry_key) && !suffix_changed { + // Unchanged entry — refresh the stored reference in place + // (its content can still differ via storage flags). An entry + // key can only be in both sets when the source value's + // null-ness matched (a null raw yields the empty key, a + // non-null one an 8-byte bucket start), and the suffix is + // unchanged here, so the old and new layouts coincide and + // either verdict describes the entry on disk. + let mut path: Vec> = base_index_path.to_vec(); + path.push(entry_key.clone()); + for segment in &new_suffix { + path.push(segment.clone()); + } + let (refresh_key, refresh_path) = if new_terminator_is_unique { + // Unique layout: the reference lives AT `[0]`. + (vec![0], path) + } else { + // Non-unique layout: `[0]` is a tree of doc-id-keyed + // references. + path.push(vec![0]); + (doc_id.to_vec(), path) + }; + self.batch_refresh_reference( + refresh_path, + refresh_key, + index_document_reference.clone(), + storage_flags.is_none(), + batch_operations, + drive_version, + )?; + continue; + } + + // Materialize every tree along the entry path — the same + // post-demotion tree types and zero-contribution wrappers the + // insert walkers use — then store the reference. The insertion + // cache prevents duplicate empty-tree operations when several + // indexes share the first property (and therefore, by the + // cross-index validation, the identical transform and buckets). + let mut path: Vec> = base_index_path.to_vec(); + let mut qualified_path = path.clone(); + qualified_path.push(entry_key.clone()); + if !batch_insertion_cache.contains(&qualified_path) { + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((path.clone(), entry_key.as_slice())), + top_value_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + path.push(entry_key.clone()); + + let mut parent_value_tree_type = top_value_tree_type; + for (i, (_level, sub_level_tree_types)) in levels.iter().enumerate() { + let property_name = &new_suffix[i * 2]; + let value = &new_suffix[i * 2 + 1]; + + let mut qualified_path = path.clone(); + qualified_path.push(property_name.clone()); + if !batch_insertion_cache.contains(&qualified_path) { + // Continuation property-name tree: contributes zero on + // every axis its aggregating parent maintains, exactly + // as on the insert path. + let property_name_tree_type = sub_level_tree_types.property_name_tree_type; + let ranked_axes = sub_level_tree_types.ranked_axes.as_slice(); + let inserted = if matches!(parent_value_tree_type, TreeType::NormalTree) { + self.batch_insert_empty_index_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((path.clone(), property_name.as_slice())), + property_name_tree_type, + ranked_axes, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )? + } else { + self.batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((path.clone(), property_name.as_slice())), + parent_value_tree_type, + property_name_tree_type, + ranked_axes, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )? + }; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + path.push(property_name.clone()); + + let mut qualified_path = path.clone(); + qualified_path.push(value.clone()); + if !batch_insertion_cache.contains(&qualified_path) { + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((path.clone(), value.as_slice())), + sub_level_tree_types.value_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + path.push(value.clone()); + + parent_value_tree_type = sub_level_tree_types.value_tree_type; + } + + if new_terminator_is_unique { + // Unique layout: no per-document subtree — the reference IS + // the element at `[0]`, so the slot must be free. Insertion + // failure means two documents claim the same (bucket, …) + // tuple, which the uniqueness validation should have caught + // before we got here; treat it as index corruption exactly as + // the non-time-range branch does. + let inserted = self.batch_insert_if_not_exists( + PathKeyRefElement::<0>((path, &[0], index_document_reference.clone())), + BatchInsertApplyType::StatefulBatchInsert, + transaction, + batch_operations, + drive_version, + )?; + if !inserted { + return Err(Error::Drive(DriveError::CorruptedContractIndexes( + "index already exists".to_string(), + ))); + } + } else { + // Non-unique layout: materialize the `[0]` reference bucket + // (it carries the index's count/sum aggregates) and store the + // reference under the document id. + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((path.clone(), &[0])), + reference_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + path.push(vec![0]); + + self.batch_insert( + PathKeyRefElement::<0>(( + path, + doc_id.as_slice(), + index_document_reference.clone(), + )), + batch_operations, + drive_version, + )?; + } + } + // Delete old entries LAST: removed keys, plus common keys whose + // sub-values changed (their old-suffix path no longer matches). The + // ordering is load-bearing: `batch_delete_up_tree_while_empty` folds + // the already-accumulated batch operations into its emptiness walk, + // so with the inserts above in place it stops before deleting a + // shared ancestor tree (bucket / property-name) that this same batch + // re-populates — e.g. on a suffix change at an unchanged timestamp. + // Deletes first would emit a delete of that ancestor alongside + // inserts into it, which grovedb's batch consistency check rejects. + for entry_key in &old_entry_keys { + if new_set.contains(entry_key) && !suffix_changed { + continue; // unchanged entry — already refreshed by the insert loop above + } + let mut key_info_path: Vec = base_index_path + .iter() + .map(|s| KnownKey(s.clone())) + .collect(); + key_info_path.push(KnownKey(entry_key.clone())); + for segment in &old_suffix { + key_info_path.push(KnownKey(segment.clone())); + } + // The entry is removed under the layout the OLD document wrote it + // with: under the unique layout `[0]` IS the reference and is the + // key being deleted, under the non-unique layout `[0]` is the + // enclosing tree and the document id is the key. The stop height + // is unaffected — the emptiness walk still climbs the suffix + // values, the property-name trees and the bucket, and still stops + // at the document-type level, whose per-index property-name trees + // belong to contract registration rather than to any document. + let delete_key: Vec = if old_terminator_is_unique { + vec![0] + } else { + key_info_path.push(KnownKey(vec![0])); + doc_id.to_vec() + }; + self.batch_delete_up_tree_while_empty( + KeyInfoPath::from_vec(key_info_path), + delete_key.as_slice(), + Some(CONTRACT_DOCUMENTS_PATH_HEIGHT), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + } + + Ok(()) + } } diff --git a/packages/rs-drive/src/drive/document/update/mod.rs b/packages/rs-drive/src/drive/document/update/mod.rs index 407db2b14b1..bbe866443e2 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -3264,6 +3264,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let sum_response = drive .execute_document_sum_request(sum_request, None, platform_version) @@ -3501,6 +3502,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let sum_response = drive .execute_document_sum_request(sum_request, None, platform_version) diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs index e7aada7b4d8..ce3f5eee587 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs @@ -65,6 +65,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation @@ -111,6 +112,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Fetch all documents diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs index 95633f0b2e9..dbcd83938b5 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs @@ -84,6 +84,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs index 64e84b84754..d96e8fbb014 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs @@ -87,6 +87,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 26bcd39f12e..7dc217fed77 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -76,6 +76,18 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // Provenance-vs-shape contract, BEFORE the prove/no-prove split: the + // no-prove path re-checks inside the joint dispatcher, but the prove + // path would otherwise reach its executors unguarded — a direct + // caller marking an `In`/range clause as time-range-resolved could + // have the pickers admit the bucketed index and prove an aggregate + // that counts a document once per overlapping bucket. The guard only + // inspects Equal clauses, so running it before range-pair + // canonicalization is equivalent to running it after. + crate::query::validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &request.resolved_time_range_fields, + )?; if request.prove { return self.execute_document_average_prove(request, transaction, platform_version); } @@ -176,6 +188,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, + &request.resolved_time_range_fields, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { @@ -262,6 +275,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, + &request.resolved_time_range_fields, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { @@ -350,6 +364,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, + &request.resolved_time_range_fields, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { @@ -599,6 +614,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -616,6 +632,7 @@ mod tests { document_type.indexes(), std::slice::from_ref(&color_gt_blue), "amount", + &[], ) .filter(|idx| idx.range_countable) .expect("byColor rangeAverageable index covers `color > blue`"); @@ -693,6 +710,7 @@ mod tests { limit: Some(over_max), prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let err = drive @@ -807,6 +825,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -884,6 +903,7 @@ mod tests { limit: None, prove: false, drive_config, + resolved_time_range_fields: vec![], }; let sum_request = DocumentSumRequest { contract, @@ -895,6 +915,7 @@ mod tests { limit: None, prove: false, drive_config, + resolved_time_range_fields: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1009,6 +1030,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let joint_response = drive @@ -1120,6 +1142,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let joint_response = drive @@ -1140,6 +1163,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1151,6 +1175,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1254,6 +1279,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let joint_response = drive @@ -1274,6 +1300,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1285,6 +1312,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1377,6 +1405,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let joint_response = drive @@ -1509,6 +1538,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let joint_response = drive @@ -1529,6 +1559,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1540,6 +1571,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1642,6 +1674,7 @@ mod tests { limit: Some(2), prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -1720,6 +1753,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -1798,6 +1832,7 @@ mod tests { limit: Some(4), prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -1853,6 +1888,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let err = drive @@ -1915,6 +1951,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let err = drive @@ -1933,6 +1970,60 @@ mod tests { ); } + /// The prove path returns before the joint dispatcher, so the + /// provenance-vs-shape guard must run at the shared entry: without it a + /// direct caller marking an `In` clause as time-range-resolved would + /// reach the prove executors, have the pickers admit a bucketed index, + /// and prove an aggregate that counts a document once per overlapping + /// bucket. + #[test] + fn avg_prove_path_rejects_resolved_time_range_provenance_on_an_in_clause() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + let in_on_resolved_field = WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(0), Value::U64(7_200_000)]), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![in_on_resolved_field], + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: true, + drive_config: &drive_config, + resolved_time_range_fields: vec!["$createdAt".to_string()], + }; + + let err = drive + .execute_document_average_request(request, None, platform_version) + .expect_err("AVG prove must reject provenance attached to an In clause"); + assert!( + format!("{err:?}").contains("InvalidWhereClauseComponents"), + "expected the provenance shape guard, got: {err:?}" + ); + } + /// `PerInValue` no-proof AVG must honor `request.limit` on the /// returned entry list. Regression for the reviewer's "joint /// dispatcher drops `request.limit`" finding on the PerInValue @@ -2017,6 +2108,7 @@ mod tests { limit: Some(2), prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -2146,6 +2238,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -2252,6 +2345,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive diff --git a/packages/rs-drive/src/query/drive_document_average_query/mod.rs b/packages/rs-drive/src/query/drive_document_average_query/mod.rs index e7576ff2499..bd51842ee07 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/mod.rs @@ -114,6 +114,13 @@ pub struct DocumentAverageRequest<'a> { pub sum_property: String, /// Structured where-clauses. pub where_clauses: Vec, + /// The fields among `where_clauses` whose equality clause was produced by + /// `IN_TIME_RANGE` resolution rather than written by the caller. Same + /// contract and same purpose as + /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// it is what gates which indexes the sum pickers (which average rides) + /// may select. + pub resolved_time_range_fields: Vec, /// Structured order-clauses. pub order_clauses: Vec, /// The average mode requested. diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index 1442184ddc7..e9da98f98e2 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -111,6 +111,11 @@ impl Drive { // the catalog of rejections. let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; + let resolved_time_range_fields = request.resolved_time_range_fields; + crate::query::validate_resolved_time_range_clause_shapes( + &where_clauses, + &resolved_time_range_fields, + )?; // Convert AverageMode → SumMode (1:1 by construction); sum's // routing table is the single source of truth for the @@ -145,6 +150,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, transaction, platform_version, @@ -170,6 +176,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, order_by_ascending, per_in_limit as u16, @@ -214,6 +221,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, return_distinct, order_by_ascending, diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs index 5a88ea33ecf..edf8f28d5d1 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -48,6 +48,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, left_to_right: bool, limit: u16, @@ -122,6 +123,7 @@ impl Drive { document_type.indexes(), &clauses_for_value, &sum_property, + resolved_time_range_fields, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index 70fbbf09ab5..de1ebf3b00d 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -71,6 +71,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, return_distinct: bool, left_to_right: bool, @@ -82,6 +83,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs index 1ea08baea67..723578efac4 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs @@ -54,6 +54,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -89,6 +90,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs index 6237e3f009a..1d696c3451a 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs @@ -62,6 +62,12 @@ pub struct DocumentCountRequest<'a> { /// the catalog of rejections this enables and the In/range + /// `between*` canonicalization rules) before mode detection. pub where_clauses: Vec, + /// The fields among `where_clauses` whose equality clause was produced by + /// `IN_TIME_RANGE` resolution rather than written by the caller. Same + /// contract and same purpose as + /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// it is what gates which indexes the count pickers may select. + pub resolved_time_range_fields: Vec, /// Structured `order_by` clauses. The first clause's direction /// governs split-mode entry ordering (per-`In`-value / /// per-distinct-value-in-range) and, on the @@ -445,6 +451,11 @@ impl Drive { // for the catalog of rejections / canonicalization rules. let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; + let resolved_time_range_fields = request.resolved_time_range_fields; + crate::query::validate_resolved_time_range_clause_shapes( + &where_clauses, + &resolved_time_range_fields, + )?; let order_clauses = request.order_clauses; // Split-mode entry direction is whatever the first orderBy @@ -476,6 +487,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, transaction, platform_version, )?; @@ -497,6 +509,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, options, transaction, platform_version, @@ -537,6 +550,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, options, transaction, platform_version, @@ -558,6 +572,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, transaction, platform_version, )?, @@ -617,6 +632,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, limit_u16, left_to_right, transaction, @@ -630,6 +646,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, transaction, platform_version, )?, @@ -717,6 +734,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, effective_limit, left_to_right, transaction, diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs index 5c9778d1efe..00f071851df 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs @@ -37,6 +37,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], options: RangeCountOptions, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -97,6 +98,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &clauses_for_value, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs index a483f1343d1..9dc6c265e71 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs @@ -32,12 +32,14 @@ impl Drive { /// fast path doesn't apply), rejects with /// `WhereClauseOnNonIndexedProperty`. Same contract on both /// prove and no-proof paths — no silent fallback. + #[allow(clippy::too_many_arguments)] pub fn execute_document_count_point_lookup_proof( &self, contract_id: [u8; 32], document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -69,6 +71,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs index 3a782e1ae68..0514a43034b 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs @@ -55,6 +55,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], limit: Option, left_to_right: bool, transaction: TransactionArg, @@ -63,6 +64,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs index 3a340a22454..ec9dce8f109 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs @@ -36,6 +36,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], limit: u16, left_to_right: bool, transaction: TransactionArg, @@ -44,6 +45,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs index 4b2e677daf7..0d5c1bf1d42 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs @@ -26,6 +26,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], options: RangeCountOptions, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -33,6 +34,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs index 3436eeae738..d0d8decbb0f 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs @@ -18,18 +18,21 @@ impl Drive { /// Range-count proof via grovedb's `AggregateCountOnRange`. /// Returns proof bytes that the client verifies via /// `GroveDb::verify_aggregate_count_query`. + #[allow(clippy::too_many_arguments)] pub fn execute_document_count_range_proof( &self, contract_id: [u8; 32], document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs index 6a00660b4c5..4590324620b 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs @@ -18,12 +18,14 @@ impl Drive { /// tree's root). /// /// Single summed entry with empty key. + #[allow(clippy::too_many_arguments)] pub fn execute_document_count_total_no_proof( &self, contract_id: [u8; 32], document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -56,6 +58,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs index 30ff6ed939c..7180916f3f5 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs @@ -6,6 +6,7 @@ use super::super::conditions::WhereClause; use super::DriveDocumentCountQuery; +use crate::query::index_admissible_for_resolved_time_range; use dpp::data_contract::document_type::Index; use std::collections::{BTreeMap, BTreeSet}; @@ -33,9 +34,16 @@ impl DriveDocumentCountQuery<'_> { /// clauses), the dispatcher reads the document-type primary-key tree's /// CountTree directly — that path doesn't use this picker because no /// index is involved. + /// + /// `resolved_time_range_fields` names the fields whose equality clause was + /// produced by `IN_TIME_RANGE` resolution (see + /// [`crate::query::resolve_time_range_bucket_clause`]); it gates which + /// indexes are candidates at all — see + /// [`index_admissible_for_resolved_time_range`]. pub fn find_countable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], ) -> Option<&'b Index> { if Self::has_unsupported_operator(where_clauses) { return None; @@ -56,6 +64,14 @@ impl DriveDocumentCountQuery<'_> { } for index in indexes.values() { + // A time-range index holds one entry per bucket containing the + // document, keyed by bucket start: counting over it multi-counts + // every document unless the query pins a single bucket, and only + // a resolution-produced equality does that. Conversely a raw + // clause must never bind to bucket keys. + if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + continue; + } if !index.countable.is_countable() { continue; } @@ -93,9 +109,18 @@ impl DriveDocumentCountQuery<'_> { /// walks the current model doesn't support). Pure point-lookup queries /// (no range operator) should fall back to /// [`Self::find_countable_index_for_where_clauses`]. + /// + /// `resolved_time_range_fields` gates the candidate set exactly as in + /// [`Self::find_countable_index_for_where_clauses`]. A resolved field + /// never arrives as a range clause — resolution always produces an + /// equality — so with a non-empty list the only bucketed index this can + /// return is one whose resolved equality is a prefix property and whose + /// range terminator is a different property. That is the intended shape: + /// a range over one property within a single time bucket. pub fn find_range_countable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], ) -> Option<&'b Index> { let range_clauses: Vec<&WhereClause> = where_clauses .iter() @@ -147,6 +172,13 @@ impl DriveDocumentCountQuery<'_> { .collect(); for index in indexes.values() { + // Same admissibility rule as the point-lookup picker: bucketed + // indexes store one entry per containing bucket, so only a query + // pinned to a single bucket by a resolution-produced equality may + // walk them, and raw clauses may never bind to bucket keys. + if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + continue; + } if !index.range_countable || !index.countable.is_countable() { continue; } diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index 116147110a5..7a258679fea 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -142,6 +142,7 @@ fn test_count_query_fully_covered_equal_succeeds_on_both_paths() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&age_eq_30), + &[], ) .expect("expected picker to accept fully-covered byAge index"); @@ -200,6 +201,7 @@ fn test_count_query_picker_rejects_partial_coverage() { let no_match = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &[], + &[], ); assert!( no_match.is_none(), @@ -217,6 +219,7 @@ fn test_count_query_picker_rejects_partial_coverage() { let no_match_partial = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &first_name_only, + &[], ); assert!( no_match_partial.is_none(), @@ -234,6 +237,7 @@ fn test_count_query_picker_rejects_partial_coverage() { let picked = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &age_only, + &[], ) .expect("byAge is exactly covered"); assert_eq!(picked.properties.len(), 1); @@ -267,6 +271,7 @@ fn test_find_countable_index_for_where_clauses_no_match() { let result = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &[where_clause], + &[], ); assert!( @@ -342,6 +347,7 @@ fn test_find_countable_index_rejects_unsupported_operator() { DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(>_clause), + &[], ) .is_none() ); @@ -374,6 +380,7 @@ fn test_count_query_total_count_with_in_operator() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&in_clause), + &[], ) .expect("expected to find countable index for In on age"); @@ -419,6 +426,7 @@ fn test_count_query_total_count_with_in_operator_no_matches() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&in_clause), + &[], ) .expect("expected to find countable index for In on age"); @@ -533,6 +541,7 @@ fn test_aggregate_count_in_fan_out_ignores_default_query_limit() { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -603,6 +612,7 @@ fn test_count_query_in_operator_rejects_duplicate_values() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&in_clause), + &[], ) .expect("expected to find countable index for In on age"); @@ -691,6 +701,7 @@ fn test_count_query_in_on_before_last_with_trailing_equal_succeeds_on_both_paths let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("expected picker to accept byFirstNameLastName for In + Equal coverage"); // Sanity-check the picker really chose the 2-prop index, not the @@ -814,6 +825,7 @@ fn test_count_query_in_on_first_of_three_with_two_trailing_equals_succeeds_on_bo let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("expected picker to accept the 3-prop covering index"); // Sanity-pin the picker actually chose the 3-prop unique @@ -943,6 +955,7 @@ fn test_point_lookup_proof_omits_absent_in_branches_from_entries() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&in_clause), + &[], ) .expect("expected picker to accept byAge for In on age"); // Sanity-pin the picker chose the single-property `byAge` index — @@ -1092,6 +1105,7 @@ fn test_count_query_in_operator_accepts_max_sized_array() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&in_clause), + &[], ) .expect("expected picker to accept byAge for In on age"); assert_eq!(index.properties.len(), 1); @@ -1331,6 +1345,7 @@ fn test_compound_range_in_summed_no_proof_uses_per_in_aggregate_fanout() { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -1405,6 +1420,7 @@ fn test_count_request_with_duplicate_equality_clauses_is_rejected() { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let err = drive @@ -1601,6 +1617,7 @@ fn test_range_distinct_proof_uses_compile_time_default_query_limit_not_operator_ limit: None, prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -1629,6 +1646,7 @@ fn test_range_distinct_proof_uses_compile_time_default_query_limit_not_operator_ let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&color_gt_blue), + &[], ) .expect("byColor range_countable index covers `color > blue`"); let count_query = DriveDocumentCountQuery { @@ -1724,6 +1742,7 @@ fn test_range_distinct_no_proof_rejects_zero_effective_limit() { limit: None, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let result = drive.execute_document_count_request(request, None, platform_version); @@ -1770,6 +1789,7 @@ fn test_count_query_in_operator_rejects_oversized_array() { document_type, "person".to_string(), vec![in_clause], + &[], super::RangeCountOptions { distinct: false, limit: Some(50), @@ -2006,6 +2026,7 @@ fn test_countable_allowing_offset_variant_end_to_end() { let picked = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&first_name_eq_alice), + &[], ) .expect("expected picker to accept CountableAllowingOffset index"); assert_eq!(picked.countable, IndexCountability::CountableAllowingOffset); @@ -2079,6 +2100,7 @@ fn test_count_query_unique_countable_index_returns_correct_count() { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, + &[], ) .expect("expected to find a countable index covering all 3 properties"); @@ -2146,6 +2168,7 @@ mod range_countable_picker_tests { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, } } @@ -2171,6 +2194,7 @@ mod range_countable_picker_tests { let picked = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ); assert!(picked.is_some()); assert_eq!(picked.unwrap().name, "byColor"); @@ -2204,6 +2228,7 @@ mod range_countable_picker_tests { let picked = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ); assert!(picked.is_some()); assert_eq!(picked.unwrap().name, "byBrandColor"); @@ -2230,6 +2255,7 @@ mod range_countable_picker_tests { DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ) .is_none(), "a range on a non-terminator property must not match — the storage \ @@ -2257,6 +2283,7 @@ mod range_countable_picker_tests { DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ) .is_none() ); @@ -2288,6 +2315,7 @@ mod range_countable_picker_tests { DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ) .is_none(), "two separate range operators must be rejected (use Between to express a bounded range)" @@ -2313,6 +2341,7 @@ mod range_countable_picker_tests { DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( &indexes, &where_clauses, + &[], ) .is_none(), "no range operator → not the range picker's job" @@ -2900,6 +2929,7 @@ mod range_countable_point_lookup_tests { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&brand_eq), + &[], ) .expect("byBrand covers brand==acme"); assert!( @@ -3016,6 +3046,7 @@ mod range_countable_point_lookup_tests { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&brand_in), + &[], ) .expect("byBrand covers brand IN [...]"); assert!(index.range_countable); @@ -3183,6 +3214,7 @@ mod range_countable_point_lookup_tests { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &clauses, + &[], ) .expect("byBrandColor covers brand IN + color ="); assert!(index.range_countable); @@ -3302,6 +3334,7 @@ mod range_countable_point_lookup_tests { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), std::slice::from_ref(&category_eq), + &[], ) .expect("byCategory covers category=tools"); assert!( @@ -3375,3 +3408,176 @@ mod range_countable_point_lookup_tests { assert_eq!(summed, 2); } } + +#[cfg(test)] +mod time_range_picker_tests { + //! Coverage for the transform gate the count pickers apply before + //! scoring a candidate — see + //! [`crate::query::index_admissible_for_resolved_time_range`]. + //! + //! A bucketed index stores one entry per bucket containing a document, + //! keyed by bucket start. Counting over it is only meaningful when the + //! query pins a single bucket, and the only thing that can pin one is an + //! equality produced by `IN_TIME_RANGE` resolution. Both directions of + //! the mismatch return a wrong count rather than an error, so the picker + //! is where they have to be stopped. + + use super::*; + use dpp::data_contract::document_type::{ + Index, IndexCountability, IndexProperty, TimeRangeTransform, + }; + + /// One hour as a transform declares a window (seconds) and as the clause + /// values below are expressed (milliseconds, the unit of a bucket start). + const HOUR_SECONDS: u64 = 3_600; + const HOUR_MS: u64 = 3_600_000; + const SOURCE: &str = "$createdAt"; + + fn make_index( + name: &str, + properties: &[&str], + time_range: Option, + ) -> Index { + Index { + name: name.to_string(), + properties: properties + .iter() + .map(|p| IndexProperty { + name: p.to_string(), + ascending: true, + }) + .collect(), + unique: false, + null_searchable: true, + contested_index: None, + countable: IndexCountability::Countable, + range_countable: false, + summable: None, + range_summable: false, + ranked_countable: false, + ranked_summable: false, + ranked_averageable: false, + time_range, + } + } + + /// `trending` buckets `$createdAt` into 6h windows every 2h; `byHashtag` + /// covers the same two fields with raw timestamps and sorts first. + fn indexes() -> std::collections::BTreeMap { + [ + make_index("byHashtag", &["hashtag", SOURCE], None), + make_index( + "trending", + &[SOURCE, "hashtag"], + Some(TimeRangeTransform { + source: SOURCE.to_string(), + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 2 * HOUR_SECONDS, + origin_seconds: 0, + }), + ), + ] + .into_iter() + .map(|index| (index.name.clone(), index)) + .collect() + } + + fn equal(field: &str, value: Value) -> WhereClause { + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value, + } + } + + /// The resolved equality names the bucketed index's source, so that index + /// — and only that index — may serve the count. + #[test] + fn resolved_source_equality_selects_the_bucketed_index() { + let indexes = indexes(); + let where_clauses = vec![ + equal(SOURCE, Value::U64(6 * HOUR_MS)), + equal("hashtag", Value::Text("ibiza".to_string())), + ]; + let picked = DriveDocumentCountQuery::find_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[SOURCE.to_string()], + ) + .expect("the bucketed index exactly covers the resolved clause set"); + assert_eq!(picked.name, "trending"); + } + + /// The same clause set without the provenance must not reach the bucketed + /// index. `byHashtag` covers it and sorts first, so this also pins that + /// the gate does not merely reorder candidates. + #[test] + fn raw_equality_on_the_source_never_selects_the_bucketed_index() { + let indexes = indexes(); + let where_clauses = vec![ + equal(SOURCE, Value::U64(1_700_000_000_000)), + equal("hashtag", Value::Text("ibiza".to_string())), + ]; + let picked = DriveDocumentCountQuery::find_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[], + ) + .expect("the plain index covers a raw equality on both fields"); + assert_eq!(picked.name, "byHashtag"); + } + + /// An `In` over the source enumerates bucket starts, and a document + /// appears under every bucket containing it, so any hit would be counted + /// once per overlapping bucket. Resolution never produces an `In`, so the + /// only way this shape reaches the picker is a client writing it by hand. + #[test] + fn raw_in_on_the_source_does_not_select_the_bucketed_index() { + let mut indexes = indexes(); + // Drop the plain index so a `None` here can only mean the bucketed + // one was refused, not that a raw index happened to win. + indexes.remove("byHashtag"); + let where_clauses = vec![ + WhereClause { + field: SOURCE.to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::U64(2 * HOUR_MS), + Value::U64(4 * HOUR_MS), + Value::U64(6 * HOUR_MS), + ]), + }, + equal("hashtag", Value::Text("ibiza".to_string())), + ]; + assert!( + DriveDocumentCountQuery::find_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[], + ) + .is_none() + ); + } + + /// The converse gate: with a resolved field named, an index that stores + /// raw timestamps is not a candidate even when it exactly covers the + /// clause fields — matching a bucket start against raw values would + /// return a proven-empty result. + #[test] + fn plain_index_is_not_selected_when_a_time_range_field_was_resolved() { + let mut indexes = indexes(); + indexes.remove("trending"); + let where_clauses = vec![ + equal(SOURCE, Value::U64(6 * HOUR_MS)), + equal("hashtag", Value::Text("ibiza".to_string())), + ]; + assert!( + DriveDocumentCountQuery::find_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[SOURCE.to_string()], + ) + .is_none() + ); + } +} diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs index 23bd902fe6e..5743d178a7a 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs @@ -17,6 +17,7 @@ use super::mode_detection::detect_ranked_mode; use super::{RankedPage, RankedPaginationInputs}; use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::having::HavingClause; use crate::query::projection::SelectProjection; @@ -64,6 +65,16 @@ pub struct DocumentRankedRequest<'a> { /// equality pins on the covering compound index's leading /// properties for the pinned-prefix form. pub where_clauses: &'a [WhereClause], + /// The fields among `where_clauses` whose equality clause was produced by + /// `IN_TIME_RANGE` resolution (see + /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]). + /// Must be empty: `where_clauses` must be empty, so there is nothing to + /// have resolved, and ranking over bucket keys is undesigned — a document + /// belongs to `overlap_factor` buckets at once, so it would contribute to + /// that many groups. Carried (and rejected) here for the same reason + /// `where_clauses` is: drive owns the rejection regardless of which + /// upstream path built the request. + pub resolved_time_range_fields: &'a [String], /// Request `limit` — the ranking's `k`. **Required**; there is no /// server default a verifying client could reproduce. pub limit: Option, @@ -128,6 +139,19 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // Unreachable behind the empty-`where_clauses` rule `detect_ranked_mode` + // enforces below — a resolved equality is a where clause — but stated + // here so the ranked surface's exclusion of bucketed indexes is a + // rejection rather than a silent fallback to another index. + if !request.resolved_time_range_fields.is_empty() { + return Err(Error::Query(QuerySyntaxError::Unsupported( + "a ranked query cannot carry a time-range (IN_TIME_RANGE) selection: ranking \ + groups by an index's only property, and a document belongs to every bucket \ + that contains its timestamp, so it would be ranked into several groups at once" + .to_string(), + ))); + } + let mode = detect_ranked_mode( &request.select, request.group_by, diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_ranked_query/index_picker.rs index 5f5408fbf30..86fdaf4414b 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/index_picker.rs @@ -36,7 +36,8 @@ use std::collections::BTreeMap; /// property is exactly `aggregate_field`. Both axes are derived from /// the same running sum the index maintains (`Avg` is that sum over the /// group's count), so summing a *different* field than the one the -/// index accumulates would silently answer about the wrong property. +/// index accumulates would silently answer about the wrong property; +/// - it carries no time-range transform. /// /// With no pins this degenerates to the original single-property rule. /// A partial pin (some but not all leading properties) matches nothing — @@ -85,6 +86,14 @@ pub fn find_ranked_index_for_axis<'b>( { return false; } + // Ranking over bucket keys is an undesigned surface: a document is + // stored once per bucket that contains it, so it would contribute to + // `overlap_factor` groups at once, and a ranked query carries no + // where clauses that could pin a single bucket. Exclude bucketed + // indexes until the semantics are deliberately designed. + if index.time_range.is_some() { + return false; + } match axis { RankedAxis::Count => index.ranked_countable, RankedAxis::Sum => { diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs b/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs index 0052ab8388e..9b503d658a4 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs @@ -690,6 +690,7 @@ fn test_index(name: &str, properties: &[&str], summable: Option<&str>) -> Index ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, } } @@ -961,6 +962,7 @@ fn run( offset: case.offset, has_start_at: false, prove, + resolved_time_range_fields: &[], }, None, platform_version(), @@ -2247,6 +2249,7 @@ mod pinned_prefix { offset: None, has_start_at: false, prove, + resolved_time_range_fields: &[], }, None, platform_version(), @@ -2464,6 +2467,7 @@ mod pinned_prefix { offset: None, has_start_at: false, prove, + resolved_time_range_fields: &[], }; let page = match drive diff --git a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs index 11d3ef38db8..c7a8c483802 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs @@ -62,6 +62,14 @@ impl Drive { let contract_id = request.contract.id().to_buffer(); let document_type_name = request.document_type.name().to_string(); let where_clauses = request.where_clauses; + let resolved_time_range_fields = request.resolved_time_range_fields; + // Same provenance-vs-shape contract as the count and joint + // dispatchers; sum has no canonicalize step, so the guard anchors + // here. + crate::query::validate_resolved_time_range_clause_shapes( + &where_clauses, + &resolved_time_range_fields, + )?; let sum_property = request.sum_property; // Default direction is ascending; the first order clause's // direction (if any) wins. Mirrors count's analog. @@ -78,6 +86,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, transaction, platform_version, @@ -97,6 +106,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, options, transaction, @@ -127,6 +137,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, options, transaction, @@ -145,6 +156,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, transaction, platform_version, @@ -196,6 +208,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, limit_u16, order_by_ascending, @@ -210,6 +223,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, transaction, platform_version, @@ -254,6 +268,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, + &resolved_time_range_fields, sum_property, limit_u16, order_by_ascending, diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs index 251cf2917f8..219320ece8e 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs @@ -29,6 +29,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, options: RangeSumOptions, transaction: TransactionArg, @@ -82,6 +83,7 @@ impl Drive { document_type.indexes(), &clauses_for_value, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs index 0c65b144623..8c8ea73514c 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs @@ -25,6 +25,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -59,6 +60,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs index 67cde266e34..c50666d36e5 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs @@ -45,6 +45,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, limit: Option, left_to_right: bool, @@ -55,6 +56,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs index e06555e1c7f..786956442c7 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs @@ -26,6 +26,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, limit: u16, left_to_right: bool, @@ -36,6 +37,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs index be1ad9c44f7..6c7038c5d22 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs @@ -23,6 +23,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, options: RangeSumOptions, transaction: TransactionArg, @@ -32,6 +33,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs index ef2b0e843f2..d793e0fa96d 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs @@ -25,6 +25,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -33,6 +34,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs index 600868a045d..70e428aa818 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs @@ -30,6 +30,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, + resolved_time_range_fields: &[String], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -65,6 +66,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs index 61f425b06fb..6d6f5a6a22d 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs @@ -15,7 +15,7 @@ //! prover and verifier reject the same set of inputs (same as count). use crate::query::drive_document_sum_query::{is_indexable_for_sum, is_range_operator}; -use crate::query::{WhereClause, WhereOperator}; +use crate::query::{index_admissible_for_resolved_time_range, WhereClause, WhereOperator}; use dpp::data_contract::document_type::Index; use std::collections::{BTreeMap, BTreeSet}; @@ -26,10 +26,16 @@ use std::collections::{BTreeMap, BTreeSet}; /// Mirror of count's `find_countable_index_for_where_clauses` with the /// additional `summable == Some(sum_property)` predicate on top of the /// strict-coverage match. +/// +/// `resolved_time_range_fields` names the fields whose equality clause was +/// produced by `IN_TIME_RANGE` resolution (see +/// [`crate::query::resolve_time_range_bucket_clause`]) and gates which indexes +/// are candidates — see [`index_admissible_for_resolved_time_range`]. pub fn find_summable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], sum_property: &str, + resolved_time_range_fields: &[String], ) -> Option<&'b Index> { // Defense-in-depth: any non-indexable operator immediately disqualifies // — the sum point-lookup path can only serve Equal/In. @@ -51,6 +57,14 @@ pub fn find_summable_index_for_where_clauses<'b>( } for index in indexes.values() { + // A time-range index holds one entry per bucket containing the + // document, keyed by bucket start: summing over it double-counts + // every document unless the query pins a single bucket, and only a + // resolution-produced equality does that. Conversely a raw clause + // must never bind to bucket keys. + if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + continue; + } // Skip if not summable OR if summable property doesn't match. match &index.summable { Some(prop) if prop == sum_property => {} @@ -77,10 +91,19 @@ pub fn find_summable_index_for_where_clauses<'b>( /// `sum_property`. /// /// Mirror of count's `find_range_countable_index_for_where_clauses`. +/// +/// `resolved_time_range_fields` gates the candidate set exactly as in +/// [`find_summable_index_for_where_clauses`]. A resolved field never arrives +/// as a range clause — resolution always produces an equality — so with a +/// non-empty list the only bucketed index this can return is one whose +/// resolved equality is a prefix property and whose range terminator is a +/// different property. That is the intended shape: a range over one property +/// within a single time bucket. pub fn find_range_summable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], sum_property: &str, + resolved_time_range_fields: &[String], ) -> Option<&'b Index> { let range_clauses: Vec<&WhereClause> = where_clauses .iter() @@ -121,6 +144,13 @@ pub fn find_range_summable_index_for_where_clauses<'b>( .collect(); for index in indexes.values() { + // Same admissibility rule as the point-lookup picker: bucketed + // indexes store one entry per containing bucket, so only a query + // pinned to a single bucket by a resolution-produced equality may + // walk them, and raw clauses may never bind to bucket keys. + if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + continue; + } if !index.range_summable { continue; } diff --git a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs index c1fb5d6fe6b..928307224ea 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs @@ -180,6 +180,12 @@ pub struct DocumentSumRequest<'a> { /// [`drive_dispatcher::where_clauses_from_value`] from the /// wire-CBOR shape). pub where_clauses: Vec, + /// The fields among `where_clauses` whose equality clause was produced by + /// `IN_TIME_RANGE` resolution rather than written by the caller. Same + /// contract and same purpose as + /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// it is what gates which indexes the sum pickers may select. + pub resolved_time_range_fields: Vec, /// Structured order-clauses (parsed via /// [`drive_dispatcher::order_clauses_from_value`]). pub order_clauses: Vec, diff --git a/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs b/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs index 3af9b4540cc..7ee7bf94a88 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs @@ -1107,6 +1107,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], platform_version: &PlatformVersion, ) -> Result { use crate::query::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; @@ -1117,6 +1118,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( @@ -1145,6 +1147,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], platform_version: &PlatformVersion, ) -> Result { use crate::query::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; @@ -1155,6 +1158,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( @@ -1183,11 +1187,13 @@ impl<'a> DriveDocumentSumQuery<'a> { /// Used by the SDK verifier-side rebuild via /// `GroveDb::verify_aggregate_sum_query_per_key` (grovedb PR #670 /// head `e98bab5f`). + #[allow(clippy::too_many_arguments)] pub fn carrier_aggregate_sum_path_query_static( contract: &DataContract, document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], limit: Option, left_to_right: bool, platform_version: &PlatformVersion, @@ -1200,6 +1206,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, + resolved_time_range_fields, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs index aa1d0566be1..1b1355916f0 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs @@ -45,6 +45,7 @@ fn summable_index(name: &str, props: &[&str], summable: Option<&str>) -> Index { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, } } @@ -64,6 +65,7 @@ fn range_summable_index(name: &str, props: &[&str], summable: &str) -> Index { ranked_countable: false, ranked_summable: false, ranked_averageable: false, + time_range: None, } } @@ -104,7 +106,8 @@ fn summable_picker_matches_single_prop_exactly() { &["recipient"], Some("amount"), )]); - let found = find_summable_index_for_where_clauses(&indexes, &[wc_equal("recipient")], "amount"); + let found = + find_summable_index_for_where_clauses(&indexes, &[wc_equal("recipient")], "amount", &[]); assert_eq!(found.map(|i| i.name.as_str()), Some("byRecipient")); } @@ -113,7 +116,7 @@ fn summable_picker_rejects_partial_coverage() { // Two-prop index with only one of the props matched by where clauses. let indexes = make_index_map(vec![summable_index("byAB", &["a", "b"], Some("amount"))]); assert!( - find_summable_index_for_where_clauses(&indexes, &[wc_equal("a")], "amount").is_none(), + find_summable_index_for_where_clauses(&indexes, &[wc_equal("a")], "amount", &[]).is_none(), "partial coverage must miss the strict picker" ); } @@ -127,7 +130,8 @@ fn summable_picker_rejects_property_mismatch() { Some("amount"), )]); assert!( - find_summable_index_for_where_clauses(&indexes, &[wc_equal("recipient")], "fee").is_none() + find_summable_index_for_where_clauses(&indexes, &[wc_equal("recipient")], "fee", &[]) + .is_none() ); } @@ -135,10 +139,13 @@ fn summable_picker_rejects_property_mismatch() { fn summable_picker_rejects_non_summable_index() { // No `summable` declaration → never picked, even if properties match. let indexes = make_index_map(vec![summable_index("byRecipient", &["recipient"], None)]); - assert!( - find_summable_index_for_where_clauses(&indexes, &[wc_equal("recipient")], "amount") - .is_none() - ); + assert!(find_summable_index_for_where_clauses( + &indexes, + &[wc_equal("recipient")], + "amount", + &[] + ) + .is_none()); } #[test] @@ -149,7 +156,8 @@ fn summable_picker_rejects_range_operator() { Some("amount"), )]); assert!( - find_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "amount").is_none(), + find_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "amount", &[]) + .is_none(), "any range operator disqualifies the point-lookup picker" ); } @@ -161,7 +169,8 @@ fn summable_picker_accepts_in_clause() { &["recipient"], Some("amount"), )]); - let found = find_summable_index_for_where_clauses(&indexes, &[wc_in("recipient")], "amount"); + let found = + find_summable_index_for_where_clauses(&indexes, &[wc_in("recipient")], "amount", &[]); assert_eq!(found.map(|i| i.name.as_str()), Some("byRecipient")); } @@ -177,7 +186,7 @@ fn range_summable_picker_matches_terminator_range() { "amount", )]); let found = - find_range_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "amount"); + find_range_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "amount", &[]); assert_eq!(found.map(|i| i.name.as_str()), Some("bySentAt")); } @@ -191,7 +200,8 @@ fn range_summable_picker_matches_prefix_equal_plus_terminator_range() { "amount", )]); let where_clauses = vec![wc_equal("recipient"), wc_gt("sentAt", 0)]; - let found = find_range_summable_index_for_where_clauses(&indexes, &where_clauses, "amount"); + let found = + find_range_summable_index_for_where_clauses(&indexes, &where_clauses, "amount", &[]); assert_eq!(found.map(|i| i.name.as_str()), Some("byRecipientTime")); } @@ -203,10 +213,13 @@ fn range_summable_picker_rejects_property_mismatch() { &["sentAt"], "amount", )]); - assert!( - find_range_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "fee") - .is_none() - ); + assert!(find_range_summable_index_for_where_clauses( + &indexes, + &[wc_gt("sentAt", 0)], + "fee", + &[] + ) + .is_none()); } #[test] @@ -216,10 +229,13 @@ fn range_summable_picker_rejects_non_range_summable() { let mut idx = range_summable_index("bySentAt", &["sentAt"], "amount"); idx.range_summable = false; let indexes = make_index_map(vec![idx]); - assert!( - find_range_summable_index_for_where_clauses(&indexes, &[wc_gt("sentAt", 0)], "amount") - .is_none() - ); + assert!(find_range_summable_index_for_where_clauses( + &indexes, + &[wc_gt("sentAt", 0)], + "amount", + &[] + ) + .is_none()); } #[test] @@ -233,7 +249,8 @@ fn range_summable_picker_rejects_range_not_on_terminator() { )]); let where_clauses = vec![wc_gt("recipient", 0)]; assert!( - find_range_summable_index_for_where_clauses(&indexes, &where_clauses, "amount").is_none() + find_range_summable_index_for_where_clauses(&indexes, &where_clauses, "amount", &[]) + .is_none() ); } @@ -452,6 +469,7 @@ mod limit_policy_regression { limit: None, prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let response = drive @@ -472,6 +490,7 @@ mod limit_policy_regression { document_type.indexes(), std::slice::from_ref(&color_gt_blue), "amount", + &[], ) .expect("byColor rangeSummable index covers `color > blue`"); let sum_query = DriveDocumentSumQuery { @@ -548,6 +567,7 @@ mod limit_policy_regression { limit: Some(over_max), prove: true, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; let err = drive @@ -619,6 +639,7 @@ mod limit_policy_regression { limit, prove: false, drive_config: &drive_config, + resolved_time_range_fields: vec![], }; for (requested, expected) in [(None, 2), (Some(1), 1), (Some(10_000), 3)] { diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 97a7dfcb5e0..f9434041666 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -570,6 +570,192 @@ impl From for Vec { } } +/// Which active time range a `TOP(timeRange(...))` selection resolves to, +/// when the index's ranges overlap (`range > step`). Time-range queries are a +/// v1-only feature; the v0 query surface is unaffected. +#[cfg(any(feature = "server", feature = "verify"))] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +pub enum TimeRangeSelector { + /// The freshest started range (largest start ≤ now). Covers the latest + /// partial slice (0..step of history). + Newest, + /// The oldest range still active at now. Covers a near-full trailing + /// window of ~range of history. Best for "trending over the last window". + Oldest, +} + +#[cfg(any(feature = "server", feature = "verify"))] +impl TimeRangeSelector { + /// The selector's wire spelling — the `IN_TIME_RANGE` clause's operand on + /// the v1 `getDocuments` wire. The single source of truth for the string + /// form: the SDK encoder, the drive-abci decoder and the wasm-sdk JSON + /// parser all go through these two functions (and the serde derive above + /// is renamed to match), so the spellings cannot drift apart. + pub fn as_str(&self) -> &'static str { + match self { + TimeRangeSelector::Newest => "newest", + TimeRangeSelector::Oldest => "oldest", + } + } + + /// Parses the wire spelling. Returns `None` for anything but the exact + /// strings [`Self::as_str`] produces. + pub fn from_string(value: &str) -> Option { + match value { + "newest" => Some(TimeRangeSelector::Newest), + "oldest" => Some(TimeRangeSelector::Oldest), + _ => None, + } + } +} + +/// Resolves a time-range selection on `field` into a concrete equality +/// [`WhereClause`] on the bucketed source field, using the index's +/// `timeRange` transform and an authoritative `block_time_ms`. +/// +/// The server supplies `block_time_ms` from current block time and the +/// verifier re-derives it from the quorum-signed response metadata `time_ms`, +/// so both produce the identical concrete equality query — the existing +/// index/count proofs apply unchanged and the engine never needs a dedicated +/// time-range operator. +/// +/// What comes back is an ordinary equality clause, byte-identical to one a +/// client could have written by hand against a raw timestamp. The fact that it +/// was *produced here* is what makes it safe to run against an index whose keys +/// are bucket starts, and that provenance is not recoverable from the clause: +/// callers must record `field` in the query's `resolved_time_range_fields` +/// (see [`DriveDocumentQuery::resolved_time_range_fields`]), which +/// [`DriveDocumentQuery::find_best_index`] and the aggregate index pickers +/// consume through [`index_admissible_for_resolved_time_range`] to pin +/// selection to the bucketed index — and to keep raw queries off it. +#[cfg(any(feature = "server", feature = "verify"))] +pub fn resolve_time_range_bucket_clause( + field: &str, + selector: TimeRangeSelector, + document_type: DocumentTypeRef, + block_time_ms: u64, +) -> Result { + // Every index bucketing `field` uses the same transform for it — the + // transform's source must be the index's first property, and two indexes + // bucketing the same field with different windows would be two different + // sources of truth for one clause — so the first match is the transform. + let transform = document_type + .indexes() + .values() + .find_map(|index| { + index + .time_range + .as_ref() + .filter(|transform| transform.source == field) + }) + .ok_or(Error::Query( + QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( + "no time-range index is defined on field \"{}\"", + field + )), + ))?; + + let bucket_start = match selector { + TimeRangeSelector::Newest => transform.newest_active_start(block_time_ms), + TimeRangeSelector::Oldest => transform.oldest_active_start(block_time_ms), + } + .ok_or(Error::Query(QuerySyntaxError::Unsupported(format!( + "no time range on \"{}\" is active yet: the current block time predates the index's \ + origin", + field + ))))?; + + Ok(WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::U64(bucket_start), + }) +} + +/// Whether `index` may serve a query whose equality clauses on +/// `resolved_time_range_fields` were produced by +/// [`resolve_time_range_bucket_clause`]. +/// +/// A time-range index does not store the source field's raw values: under its +/// first property it stores bucket *starts*, and one document is stored once +/// per bucket that contains its timestamp. So the two kinds of index are not +/// interchangeable in either direction, and both mismatches are silent — +/// they return a validly-proven wrong answer rather than an error: +/// +/// - A raw query (`resolved_time_range_fields` empty) that landed on a +/// bucketed index would compare a real timestamp against bucket starts and +/// see nothing (or, for range/IN shapes, walk overlapping buckets and count +/// the same document up to `overlap_factor` times). +/// - A resolved query that landed on a raw index would compare a bucket start +/// against real timestamps and see nothing. +/// +/// Hence the rule: with no resolved field only non-bucketed indexes are +/// admissible, and with one resolved field only the index bucketing exactly +/// that field is. Two resolved fields can never be served by a single index — +/// a transform's source must be its index's first property, so one index +/// buckets exactly one field — and are rejected by the caller. +#[cfg(any(feature = "server", feature = "verify"))] +pub fn index_admissible_for_resolved_time_range( + index: &Index, + resolved_time_range_fields: &[String], +) -> bool { + match resolved_time_range_fields { + [] => index.time_range.is_none(), + [field] => index + .time_range + .as_ref() + .is_some_and(|transform| transform.source == *field), + _ => false, + } +} + +/// Rejects a query whose resolution provenance and clause shapes disagree: +/// every field in `resolved_time_range_fields` must appear in the where +/// clauses as exactly one `Equal` clause — the only shape +/// [`resolve_time_range_bucket_clause`] produces. +/// +/// A range or `In` clause on a resolved field means the caller attached +/// provenance to a clause the resolver never built. Executors that fan a +/// clause out per value (the per-`In`-value count/sum paths rewrite each `In` +/// value into an equality) would then present raw client values to the index +/// pickers as if they were resolved bucket starts, and the pickers would +/// admit the bucketed index for them. The wire path can never produce the +/// mismatch — provenance is not parseable from the wire, and the abci handler +/// pushes the resolved equality itself — so this guards direct API callers, +/// and it runs identically under `server` and `verify`. +#[cfg(any(feature = "server", feature = "verify"))] +pub fn validate_resolved_time_range_clause_shapes( + where_clauses: &[WhereClause], + resolved_time_range_fields: &[String], +) -> Result<(), Error> { + for field in resolved_time_range_fields { + let mut equalities = 0usize; + for clause in where_clauses.iter().filter(|c| &c.field == field) { + if clause.operator == WhereOperator::Equal { + equalities += 1; + } else { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "a time-range-resolved field may only carry the single equality its \ + resolution produced, not a range or In clause", + ), + )); + } + } + if equalities != 1 { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "a time-range-resolved field must carry exactly one equality clause — the \ + one its resolution produced", + ), + )); + } + } + Ok(()) +} + #[cfg(any(feature = "server", feature = "verify"))] /// Drive query struct #[derive(Debug, PartialEq, Clone)] @@ -592,6 +778,22 @@ pub struct DriveDocumentQuery<'a> { pub start_at_included: bool, /// Block time pub block_time_ms: Option, + /// The fields whose equality clause in `internal_clauses` was produced by + /// `IN_TIME_RANGE` resolution — i.e. by + /// [`resolve_time_range_bucket_clause`], on the server from committed + /// block time and in the verifier from the quorum-signed response metadata + /// time. + /// + /// Never parsed from the wire: every `from_cbor` / `from_value` / + /// `from_typed_clauses` entry point leaves this empty, so a client cannot + /// claim resolution it did not go through. It is what + /// [`Self::find_best_index`] uses to pin index selection to the index that + /// buckets the field (see [`index_admissible_for_resolved_time_range`]), + /// which is required because the resolved clause is an ordinary equality + /// and cannot be told apart from a raw-timestamp lookup once built. + /// + /// Empty for every raw query. + pub resolved_time_range_fields: Vec, } impl<'a> DriveDocumentQuery<'a> { @@ -622,6 +824,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], } } @@ -638,6 +841,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: true, block_time_ms: None, + resolved_time_range_fields: vec![], } } @@ -658,6 +862,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: true, block_time_ms: None, + resolved_time_range_fields: vec![], } } @@ -884,6 +1089,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms, + resolved_time_range_fields: vec![], }) } @@ -1030,6 +1236,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms, + resolved_time_range_fields: vec![], }) } @@ -1194,6 +1401,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms: None, + resolved_time_range_fields: vec![], }) } @@ -1611,10 +1819,64 @@ impl<'a> DriveDocumentQuery<'a> { /// ([`Self::find_best_index_for_multiple_in_clauses`]); they only /// reach it through the v1 (protocol version 14+) path-query /// lowering, since the v0 lowering rejects them first. + /// + /// Selection is restricted to the indexes admissible for this query's + /// [`Self::resolved_time_range_fields`]: a query carrying an + /// `IN_TIME_RANGE`-resolved equality may only be served by the index that + /// buckets that field, and a raw query may never be served by a bucketed + /// index. See [`index_admissible_for_resolved_time_range`] for why either + /// mismatch would produce a validly-proven wrong answer. The rule applies + /// on both routes, including the multiple-`In` selection. pub fn find_best_index(&self, platform_version: &PlatformVersion) -> Result<&Index, Error> { + // A transform's source must be its index's first property, so one + // index buckets exactly one field and no index can carry two resolved + // equalities. Serving such a query would need a join across two + // bucketed indexes, which the engine has no shape for. This runs + // before any routing so the multiple-`In` path cannot bypass it. + if self.resolved_time_range_fields.len() > 1 { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "at most one time-range selection (IN_TIME_RANGE) is supported per query; this \ + one resolves {:?}, and no single index can bucket more than one field", + self.resolved_time_range_fields + )))); + } + if self.internal_clauses.in_clauses.len() > 1 { + // The multiple-`In` selection filters its candidates through the + // same admissibility rule, but it returns early and so bypasses + // the residual source-shape guard at the bottom of this function. + // Enforce the resolved-field contract here instead: the resolved + // equality must be present, and the bucketed source must not also + // carry an `In`, a range, or an ordering. + if let Some(source) = self.resolved_time_range_fields.first() { + let has_equality_on_source = + self.internal_clauses.equal_clauses.contains_key(source); + let in_or_range_on_source = self + .internal_clauses + .in_clauses + .iter() + .any(|clause| &clause.field == source) + || self + .internal_clauses + .range_clause + .as_ref() + .is_some_and(|clause| &clause.field == source); + if !has_equality_on_source + || in_or_range_on_source + || self.order_by.contains_key(source) + { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "the index on \"{}\" buckets it into time ranges: it can only be \ + queried through a time-range selection (IN_TIME_RANGE, which resolves \ + to an exact bucket equality), not with ranges, IN, or ordering on \ + that property", + source + )))); + } + } return Ok(self.find_best_index_for_multiple_in_clauses()?.0); } + let equal_fields = self .internal_clauses .equal_clauses @@ -1654,23 +1916,81 @@ impl<'a> DriveDocumentQuery<'a> { let (index, difference) = self .document_type - .index_for_types( + .index_for_types_matching( fields.as_slice(), in_field, order_by_keys.as_slice(), + |index| { + index_admissible_for_resolved_time_range( + index, + &self.resolved_time_range_fields, + ) + }, platform_version, )? - .ok_or(Error::Query( - QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( + .ok_or_else(|| match self.resolved_time_range_fields.first() { + // A time-range query is only servable by the index that + // buckets the field, so "no index" here is a narrower fact + // than the generic case: some index buckets the field (the + // clause could not have been resolved otherwise), but none + // that does also covers the rest of the query. + Some(field) => { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( + "a time-range query on \"{}\" requires an index that buckets it AND \ + covers the query's other where and order-by fields; valid indexes \ + are: {:?}", + field, + self.document_type.indexes() + ))) + } + None => Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( "query must be for valid indexes, valid indexes are: {:?}", self.document_type.indexes() - )), - ))?; + ))), + })?; if difference > defaults::MAX_INDEX_DIFFERENCE { return Err(Error::Query(QuerySyntaxError::QueryTooFarFromIndex( "query must better match an existing index", ))); } + + // Candidate filtering already guarantees a transform-carrying index is + // only reachable when the equality on its source came from + // IN_TIME_RANGE resolution. What it cannot rule out is a query that + // carries that resolved equality AND some other shape on the same + // source — a range, an IN, or an ordering — riding along: those walk + // overlapping bucket keys and return each document up to + // `overlap_factor` times with a perfectly valid proof. Reject them + // here rather than serve a provably wrong answer. The + // `!has_equality_on_source` arm is defensive: resolution always + // pushes the equality, so reaching it means the provenance and the + // clauses disagree. This runs identically on the server and in proof + // verification. + if let Some(transform) = &index.time_range { + let source = transform.source.as_str(); + let has_equality_on_source = self.internal_clauses.equal_clauses.contains_key(source); + let range_or_in_on_source = self + .internal_clauses + .range_clause + .as_ref() + .map(|c| c.field == source) + .unwrap_or(false) + || self + .internal_clauses + .in_clauses + .iter() + .any(|c| c.field == source); + let orders_on_source = self.order_by.contains_key(source); + if !has_equality_on_source || range_or_in_on_source || orders_on_source { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "the index on \"{}\" buckets it into time ranges: it can only be queried \ + through a time-range selection (IN_TIME_RANGE, which resolves to an exact \ + bucket equality), not with ranges, IN, or ordering on that property", + source + )))); + } + } + Ok(index) } @@ -2399,6 +2719,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let path_query = query_asc @@ -2779,6 +3100,47 @@ mod tests { .expect_err("starts with can not start with an empty string"); } + #[test] + fn resolved_time_range_shape_guard_accepts_only_the_single_resolution_equality() { + use crate::query::validate_resolved_time_range_clause_shapes; + + let resolved = vec!["$createdAt".to_string()]; + let equality = WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::Equal, + value: Value::U64(21_600_000), + }; + let other = WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("ibiza".to_string()), + }; + + validate_resolved_time_range_clause_shapes(&[equality.clone(), other.clone()], &resolved) + .expect("one equality on the resolved field is the resolution shape"); + + // An `In` on the resolved field would be fanned out per raw value by + // the aggregate executors and admitted against bucket keys. + let in_clause = WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(0), Value::U64(7_200_000)]), + }; + validate_resolved_time_range_clause_shapes(&[in_clause, other.clone()], &resolved) + .expect_err("an In clause on a resolved field must be rejected"); + + let range_clause = WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(0), + }; + validate_resolved_time_range_clause_shapes(&[equality.clone(), range_clause], &resolved) + .expect_err("a range clause riding along on a resolved field must be rejected"); + + validate_resolved_time_range_clause_shapes(&[other], &resolved) + .expect_err("a resolved field with no equality at all must be rejected"); + } + #[test] fn test_withdrawal_query_with_missing_transaction_index() { // Setup the withdrawal contract @@ -2832,6 +3194,7 @@ mod tests { start_at: Some([3u8; 32]), start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // Create a document that we are starting at, which may be missing 'transactionIndex' @@ -2948,6 +3311,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], } } diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs index 0cf2bb67207..7e69a445f21 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs @@ -292,6 +292,16 @@ impl<'a> DriveDocumentQuery<'a> { let equality_len = equal_clauses.len(); let mut best: Option<(&Index, Vec<&WhereClause>, u16)> = None; for index in self.document_type.indexes().values() { + // Same admissibility rule as the single-`In` selection in + // `find_best_index`: a bucketed index only for a query whose + // resolved equality names its transform source, never for a raw + // query. See `index_admissible_for_resolved_time_range`. + if !crate::query::index_admissible_for_resolved_time_range( + index, + &self.resolved_time_range_fields, + ) { + continue; + } let mut positioned: Vec<(usize, &WhereClause)> = Vec::with_capacity(in_clauses.len()); for in_clause in in_clauses { match index diff --git a/packages/rs-drive/src/verify/document/verify_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_proof/mod.rs index 46cde078b47..301ebddad6b 100644 --- a/packages/rs-drive/src/verify/document/verify_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof/mod.rs @@ -77,6 +77,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let result = query.verify_proof(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs index 7909d0eb6bf..20e2447fd24 100644 --- a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs @@ -82,6 +82,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let result = query.verify_proof_keep_serialized(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs index b2dc87e9a23..2059c084305 100644 --- a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs @@ -94,6 +94,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; let result = diff --git a/packages/rs-drive/tests/query_tests.rs b/packages/rs-drive/tests/query_tests.rs index 67ee17dff46..8d5789611e6 100644 --- a/packages/rs-drive/tests/query_tests.rs +++ b/packages/rs-drive/tests/query_tests.rs @@ -9055,6 +9055,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; // The current shape: the In clause in in_clauses @@ -9076,6 +9077,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at: None, start_at_included: false, block_time_ms: None, + resolved_time_range_fields: vec![], }; for protocol_version in [13u32, 14u32] { diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 192028514d2..76579f3a0d7 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -515,6 +515,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_group_size: 256, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, + max_time_range_overlap_factor: None, }, consensus: ConsensusVersions { tenderdash_consensus_version: 0, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 4e5a8881c9d..0854a6c6f3b 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -86,6 +86,20 @@ pub struct SystemLimits { // do this that much pub max_token_redemption_cycles: u32, pub max_shielded_transition_actions: u16, + /// Maximum overlap factor (`range / step`) a `timeRange` index transform + /// may declare, enforced at contract registration. + /// + /// The overlap factor is the number of buckets that contain any given + /// timestamp — i.e. the write amplification of the index: every document + /// insert, delete, and (on a bucket-set change) update fans out into that + /// many index entries. The bound of 24 covers the natural worst case, a + /// day-long window sliding hourly, without letting a contract buy a + /// 256-entry fan-out per document. + /// + /// `None` preserves the behavior of protocol versions that predate + /// time-range indexes (nothing to bound: the `timeRange` keyword does not + /// parse there). + pub max_time_range_overlap_factor: Option, } #[cfg(test)] diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 9f6a9218ac1..352d3ee490c 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -42,4 +42,5 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { // only becomes reachable if the size limit is raised. Pinned by dpp's // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, + max_time_range_overlap_factor: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 0aff7000acd..3b3f46d5e43 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -30,4 +30,5 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { // only becomes reachable if the size limit is raised. Pinned by dpp's // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, + max_time_range_overlap_factor: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 92dda4cf3d6..cdf3248de17 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -32,4 +32,5 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { // only becomes reachable if the size limit is raised. Pinned by dpp's // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, + max_time_range_overlap_factor: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 486a50c77b9..efc8d72578e 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -2,13 +2,18 @@ use crate::version::system_limits::SystemLimits; /// System limits for protocol version 14 and above. /// -/// Identical to [`super::v3::SYSTEM_LIMITS_V3`] except that the daily withdrawal limit becomes -/// relative: `daily_withdrawal_limit_percent` is set to 15, so Platform pools at most 15% of the -/// total credits it held a day ago into asset unlock transactions per 24 hours — never below one -/// maximal withdrawal and never above `max_daily_withdrawal_amount`, Core's 4000 Dash unlock -/// capacity per day — instead of the flat 2000 Dash that applied from v8 (matching Core v22's -/// `LimitAmountV22`). v13 is already live on networks with the flat limit, so the change gates -/// here. +/// Identical to [`super::v3::SYSTEM_LIMITS_V3`] except for two changes: +/// +/// * The daily withdrawal limit becomes relative: `daily_withdrawal_limit_percent` is set to 15, +/// so Platform pools at most 15% of the total credits it held a day ago into asset unlock +/// transactions per 24 hours — never below one maximal withdrawal and never above +/// `max_daily_withdrawal_amount`, Core's 4000 Dash unlock capacity per day — instead of the +/// flat 2000 Dash that applied from v8 (matching Core v22's `LimitAmountV22`). v13 is already +/// live on networks with the flat limit, so the change gates here. +/// * `max_time_range_overlap_factor` is set: a `timeRange` index transform may declare at most +/// 24 overlapping windows per timestamp (a day-long window sliding hourly). The rule cannot +/// exist before v14 because the `timeRange` keyword itself is only admitted by the v14 +/// document meta-schema. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -35,4 +40,5 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { // only becomes reachable if the size limit is raised. Pinned by dpp's // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, + max_time_range_overlap_factor: Some(24), }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index c461baedfe1..9c5fb9d356b 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -30,7 +30,7 @@ use crate::version::ProtocolVersion; pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; -/// v14 hosts four consensus changes: +/// v14 hosts five consensus changes: /// /// 1. **Contract-level ranked aggregates** (this branch): an index can /// declare that its groups are rankable by an aggregate, so a query like @@ -93,6 +93,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// checked only at block level, so any daily total is still minable across /// blocks; after V24 it enforces 4000 Dash per 576-block window, which the /// cap above never exceeds. +/// 5. **Time-range indexes**: an index can declare a `timeRange` transform +/// that buckets a required system timestamp (`$createdAt` / +/// `$updatedAt` / `$transferredAt`) into fixed-length, regularly-spaced, +/// optionally overlapping windows declared in seconds. A document is +/// stored once per containing bucket (the v2 insert/delete and v1 +/// update walkers carry the fan-out; the per-document write +/// amplification is capped by `SystemLimits:: +/// max_time_range_overlap_factor`), and the v1 `getDocuments` handler +/// resolves the new `IN_TIME_RANGE` operator into a bucket-start +/// equality from committed block time, making "newest window" +/// trending/leaderboard document and count/sum/avg queries provable. +/// `unique: true` is admitted only for non-overlapping windows +/// (`range == step`) sourced from the immutable `$createdAt`. /// /// The first two are orthogonal by construction: the ranked upgrade decides the /// *property-name* tree type, the demotion decides the *value* tree type @@ -101,16 +114,17 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// variant did — so ranked secondaries keep ranking correctly over /// shared-prefix shapes. /// -/// Until a contract uses the ranked grammar, the only v14 behavior changes -/// are the shared-prefix fix, the contested-index cross-check, the -/// index-reorder schema-compatibility fix and the relative daily withdrawal -/// limit; everything else matches v13: +/// Until a contract uses the ranked or time-range grammar, the only v14 +/// behavior changes are the shared-prefix fix, the contested-index +/// cross-check, the index-reorder schema-compatibility fix and the relative +/// daily withdrawal limit; everything else matches v13: /// /// * `CONTRACT_VERSIONS_V6` points `document_type_schema` at the v3 document /// meta-schema, which hosts the ranked index keywords -/// (`rankedCountable` / `rankedSummable` / `rankedAverageable`). v13 keeps -/// validating against meta-schema v2, where those keys are rejected as -/// unknown properties, so a pre-v14 contract cannot smuggle them in. +/// (`rankedCountable` / `rankedSummable` / `rankedAverageable`), the +/// `refersTo` reference keyword and the `timeRange` index transform. v13 +/// keeps validating against meta-schema v2, where those keys are rejected +/// as unknown properties, so a pre-v14 contract cannot smuggle them in. /// It also bumps `validate_schema_compatibility` to 1, which strips the /// top-level `indices` key before diffing the old and new document type /// schemas: index immutability is enforced by `validate_update` v1's @@ -122,7 +136,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// creating the three indexed tree variants and the verify-method slot for /// `verify_ranked_top_k_proof`. All are 0 today. The same table bumps the /// four index walkers to v2 and the document update walker to v1 for the -/// shared-prefix fix. +/// shared-prefix fix; those same walker versions carry the time-range +/// bucket fan-out, so both features gate on one table entry. /// * `DRIVE_ABCI_QUERY_VERSIONS_V3` bumps /// `document_query_helpers.compute_aggregate_mode_and_check_limit` 0 → 2, /// opening two routes on the v1 document-query handler: the ranked path @@ -156,10 +171,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// formats 0–2 (all pre-v14 documents) deserialize exactly as before with /// an unstamped (pre-annotation) layout. /// -/// The wire surface is deliberately unchanged: `GetDocumentsRequestV1` +/// The wire surface changes only additively: `GetDocumentsRequestV1` /// already carries `selects` / `group_by` / `order_by` / `limit` / /// `offset`; the ranked response is an additive `ResultData.ranked` -/// variant, whose `skipped` field is likewise additive. +/// variant, whose `skipped` field is likewise additive; and the v1 +/// where-clause operator enum gains `IN_TIME_RANGE = 11`, which pre-v14 +/// servers reject as an unknown operator rather than misread (the v0 wire +/// has no time-range operator at all). pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot @@ -168,7 +186,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history - query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate + query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { @@ -178,7 +196,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, state_transitions: STATE_TRANSITION_VERSIONS_V3, - contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked index keywords + contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked, refersTo, requiredSince and timeRange keywords document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V2, @@ -189,7 +207,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { }, system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33) fee_version: FEE_VERSION2, - system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) consensus: ConsensusVersions { tenderdash_consensus_version: 1, }, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index a0a7a919204..126a0803f3e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -114,6 +114,7 @@ impl DashPayView<'_, B> { operator: WhereOperator::Equal, value: platform_value!(identity_id), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], // Load-bearing, not cosmetic: drive answers a bare diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 309a30f8800..11df0c62951 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -520,6 +520,7 @@ fn domain_by_normalized_label_query( value: Value::Text(normalized_label), }, ], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -561,6 +562,7 @@ fn history_by_source_document_query( value: Value::Identifier(source_document_id.to_buffer()), }, ], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { @@ -662,6 +664,7 @@ impl IdentityWallet { data_contract: contract, document_type_name: DPNS_DOCUMENT_TYPE.to_string(), where_clauses, + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { @@ -766,6 +769,7 @@ impl IdentityWallet { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.to_buffer()), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -3699,6 +3703,7 @@ mod tests { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.to_buffer()), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 9cc79a181db..e4c041d2724 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -746,6 +746,7 @@ fn single_profile_query( operator: WhereOperator::Equal, value: platform_value!(identity_id), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -779,6 +780,7 @@ fn contact_profiles_chunk_query( operator: WhereOperator::In, value: in_values, }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs index 5e4d20947ad..841e66676e3 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs @@ -105,6 +105,7 @@ impl Sdk { data_contract: dashpay_contract.clone(), document_type_name: "contactRequest".to_string(), where_clauses: where_clauses.clone(), + time_range_clauses: vec![], group_by: vec![], having: vec![], // Load-bearing: a bare secondary-index equality with no diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 2df034fad35..0955ea24454 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -341,6 +341,7 @@ impl Sdk { value: Value::Text(normalized_label), }, ], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -400,6 +401,7 @@ impl Sdk { value: Value::Text(normalized_label), }, ], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], diff --git a/packages/rs-sdk/src/platform/dpns_usernames/queries.rs b/packages/rs-sdk/src/platform/dpns_usernames/queries.rs index 499091c505a..87f36b6ad03 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/queries.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/queries.rs @@ -55,6 +55,7 @@ impl Sdk { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.to_buffer()), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], // Remove ordering by $createdAt as it might not be indexed @@ -142,6 +143,7 @@ impl Sdk { value: Value::Text(normalized_prefix), }, ], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { diff --git a/packages/rs-sdk/tests/fetch/document.rs b/packages/rs-sdk/tests/fetch/document.rs index 3edb4501325..e4eef2bdb05 100644 --- a/packages/rs-sdk/tests/fetch/document.rs +++ b/packages/rs-sdk/tests/fetch/document.rs @@ -132,6 +132,7 @@ async fn document_list_drive_query() { start_at: None, start_at_included: true, block_time_ms: None, + resolved_time_range_fields: vec![], }; let docs = Document::fetch_many(&sdk, query) diff --git a/packages/wasm-drive-verify/src/document/verify_proof.rs b/packages/wasm-drive-verify/src/document/verify_proof.rs index b3cbfbbf726..fd7d1b1b1ee 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof.rs @@ -108,6 +108,7 @@ pub fn verify_document_proof( start_at: start_at_bytes, start_at_included, block_time_ms, + resolved_time_range_fields: vec![], }; let (root_hash, documents) = query diff --git a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs index 5ff7f822cd0..58f3e7b4099 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs @@ -99,6 +99,7 @@ pub fn verify_document_proof_keep_serialized( start_at: start_at_bytes, start_at_included, block_time_ms, + resolved_time_range_fields: vec![], }; let (root_hash, serialized_docs) = query diff --git a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs index 3141260706c..29a4fb6dcc8 100644 --- a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs @@ -107,6 +107,7 @@ pub fn verify_start_at_document_in_proof( start_at: start_at_bytes, start_at_included, block_time_ms, + resolved_time_range_fields: vec![], }; let (root_hash, document_option) = query diff --git a/packages/wasm-sdk/src/dpns.rs b/packages/wasm-sdk/src/dpns.rs index d1c7099b5b7..c2f0677b4f7 100644 --- a/packages/wasm-sdk/src/dpns.rs +++ b/packages/wasm-sdk/src/dpns.rs @@ -277,6 +277,7 @@ impl WasmSdk { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.to_buffer()), }], + time_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], diff --git a/packages/wasm-sdk/src/queries/document.rs b/packages/wasm-sdk/src/queries/document.rs index 55f4c06f92e..fb1d67f0a6f 100644 --- a/packages/wasm-sdk/src/queries/document.rs +++ b/packages/wasm-sdk/src/queries/document.rs @@ -11,7 +11,7 @@ use dash_sdk::platform::documents::document_history_query::DocumentHistoryQuery; use dash_sdk::platform::documents::document_query::DocumentQuery; use dash_sdk::platform::Fetch; use dash_sdk::platform::FetchMany; -use drive::query::{OrderClause, WhereClause, WhereOperator}; +use drive::query::{OrderClause, TimeRangeSelector, WhereClause, WhereOperator}; use drive_proof_verifier::types::DocumentHistory; use drive_proof_verifier::{DocumentSplitAverages, DocumentSplitCounts, DocumentSplitSums}; use js_sys::{BigInt, Map}; @@ -119,6 +119,20 @@ export interface DocumentsQuery { * @default [] */ groupBy?: string[]; + + /** + * Time-range bucket selections for "trending"-style queries. Each entry + * picks a single bucket of a timestamp field covered by a `timeRange` + * index. The server resolves the bucket from the current block time and + * the proof verifier re-derives it from the signed response metadata, so + * the result is provable. v1 / Platform v3.1+ only. + * + * - `selector: "oldest"` → the oldest still-active range (a near-full + * trailing window of ~`range`; best for "trending over the last window"). + * - `selector: "newest"` → the freshest started range (latest partial slice). + * @default [] + */ + timeRange?: { field: string; selector: "newest" | "oldest" }[]; } /** @@ -195,6 +209,10 @@ struct DocumentsQueryInput { // `orderBy` field — the first clause's direction controls // split-mode entry ordering and `(In + prove)` walk order. No // separate `orderByAscending` knob. + /// Time-range bucket selections (`IN_TIME_RANGE`), each `{ field, + /// selector }`. v1-only; resolved server-side from block time. + #[serde(rename = "timeRange", default)] + time_range: Option>, } #[derive(Deserialize)] @@ -243,6 +261,7 @@ async fn build_documents_query( start_after, start_at, group_by: _, + time_range, } = input; let contract_id: Identifier = data_contract_id.into(); @@ -277,6 +296,13 @@ async fn build_documents_query( } } + if let Some(time_range_values) = time_range { + for clause_json in time_range_values.iter() { + let (field, selector) = parse_time_range_clause(clause_json)?; + query = query.with_time_range(field, selector); + } + } + if let Some(order_values) = order_by { for clause_json in order_values.iter() { let order_clause = parse_order_clause(clause_json)?; @@ -456,6 +482,33 @@ fn parse_where_clause(json_clause: &JsonValue) -> Result Result<(String, TimeRangeSelector), WasmSdkError> { + let object = json_clause.as_object().ok_or_else(|| { + WasmSdkError::invalid_argument("timeRange clause must be an object { field, selector }") + })?; + let field = object + .get("field") + .and_then(JsonValue::as_str) + .ok_or_else(|| { + WasmSdkError::invalid_argument("timeRange clause requires a string `field`") + })? + .to_string(); + let selector = object + .get("selector") + .and_then(JsonValue::as_str) + .and_then(TimeRangeSelector::from_string) + .ok_or_else(|| { + WasmSdkError::invalid_argument( + "timeRange clause `selector` must be \"newest\" or \"oldest\"", + ) + })?; + Ok((field, selector)) +} + /// Parse JSON order by clause into OrderClause fn parse_order_clause(json_clause: &JsonValue) -> Result { let clause_array = json_clause From 606c0a8b57778b71d69769db5c8651d20b0a2b05 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 17:48:48 +0200 Subject: [PATCH 02/20] test(drive-abci): verify time-range aggregates through the SDK FromProof entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-by-step round trips re-ran the client sequence manually, so the layer that wires resolution, provenance, shape guard, index pick and proof verification together — the aggregate FromProof impls in dash-platform-queries — had no proof traverse it. COUNT, SUM and AVG now each verify a real signed handler proof through their entry point over an overlapping-window bucketed index (SUM/AVG on a new countable+summable time-range fixture), and each rejects a signed metadata time moved one step, pinning that the bucket comes from the signature-bound time and nowhere else. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + packages/rs-drive-abci/Cargo.toml | 1 + .../src/query/document_query/v1/tests.rs | 345 ++++++++++++++++++ 3 files changed, 347 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8deb59fae54..83c098169ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2232,6 +2232,7 @@ dependencies = [ "console-subscriber", "dapi-grpc", "dash-platform-macros", + "dash-platform-queries", "delegate", "derive_more 1.0.0", "dotenvy", diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 6d8a854e32d..6bba018371c 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -90,6 +90,7 @@ rocksdb = { git = "https://github.com/QuantumExplorer/rust-rocksdb.git", rev = " blake3 = "1.5" [dev-dependencies] +dash-platform-queries = { path = "../dash-platform-queries" } platform-version = { path = "../rs-platform-version", features = [ "mock-versions", ] } diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index a7320f4419e..f74d90812be 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -4817,4 +4817,349 @@ mod time_range_proof_verification { members — the older #ibiza post and the #berlin post are not" ); } + + // ----- the SDK `FromProof` entry points ------------------------------- + // + // The tests above re-run the client sequence step by step so a failure + // names the broken link. These run the same signed responses through the + // aggregate `FromProof` entry points the SDK actually + // exposes — resolution from signed metadata time, provenance, shape + // guard, index pick and proof verification all happen *inside* the call + // — so a regression in how that layer wires the steps together (not just + // in a step) turns a test red. + + use dash_platform_queries::documents::document_query::DocumentQuery as SdkDocumentQuery; + use drive::query::SelectProjection; + use drive_proof_verifier::{DocumentAverage, DocumentCount, DocumentSum}; + use std::sync::Arc; + + const SUMMABLE_INDEX: &str = "trendingLikes"; + + /// `likes` per post, aligned with [`POSTS`]: the two newest-bucket + /// `#ibiza` posts carry 10 and 30 (sum 40, average 20), the older + /// `#ibiza` post carries 100 and the `#berlin` post 7 — both excluded, + /// so an aggregate that leaked either is off by an unmistakable amount. + const LIKES: [u64; 4] = [10, 30, 100, 7]; + + /// The summable twin of [`register_trending_contract`]: the same + /// six-hour/two-hour bucketed `(timeRange($createdAt), hashtag)` index, + /// but countable *and* summable over a `likes` property — the CountSum + /// value trees that SUM reads and AVG derives `(count, sum)` from. The + /// plain `byHashtag` index mirrors both aggregate declarations so index + /// selection again has a wrong-but-covering candidate to reject. + fn register_engagement_contract( + platform: &Platform, + platform_version: &PlatformVersion, + ) -> DataContract { + let factory = DataContractFactory::new(platform_version.protocol_version) + .expect("expected a factory"); + let schemas = platform_value!({ + DOCUMENT_TYPE: { + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + "likes": { "type": "integer", "position": 1 }, + }, + "indices": [ + { + "name": SUMMABLE_INDEX, + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "countable": true, + "summable": "likes", + "timeRange": { "on": "$createdAt", "range": 21_600u64, "step": 7_200u64 }, + }, + { + "name": "byHashtag", + "properties": [{ "hashtag": "asc" }, { "$createdAt": "asc" }], + "countable": true, + "summable": "likes", + }, + ], + "required": ["$createdAt", "hashtag", "likes"], + "additionalProperties": false, + } + }); + let contract = factory + .create_with_value_config(Identifier::new([8u8; 32]), 0, schemas, None, None) + .expect("the engagement contract is well-formed") + .data_contract_owned(); + store_data_contract(platform, &contract, platform_version); + contract + } + + /// [`insert_posts`] with a `likes` value from [`LIKES`] on each post. + fn insert_engagement_posts( + platform: &Platform, + contract: &DataContract, + platform_version: &PlatformVersion, + ) { + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + for (i, ((created_at_ms, hashtag), likes)) in POSTS.iter().zip(LIKES).enumerate() { + let mut document: Document = document_type + .random_document(Some(8_000 + i as u64), platform_version) + .expect("random document"); + document.set_properties(BTreeMap::from([ + ("hashtag".to_string(), Value::Text(hashtag.to_string())), + ("likes".to_string(), Value::U64(likes)), + ])); + document.set_created_at(Some(*created_at_ms)); + store_document( + platform, + contract, + document_type, + &document, + platform_version, + ); + } + } + + /// Engagement contract registered, liked posts inserted, and a platform + /// state whose committed block time is [`BLOCK_TIME_MS`]. + fn setup_engagement( + platform: &TempPlatform, + base_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> (DataContract, PlatformState) { + let contract = register_engagement_contract(platform, platform_version); + insert_engagement_posts(platform, &contract, platform_version); + let state = state_with_committed_block_time(base_state, &platform.drive, platform_version); + (contract, state) + } + + /// The query a real SDK caller would build: the selector still pending in + /// `time_range_clauses`, to be resolved by the entry point itself from + /// the signed metadata time. + fn sdk_query( + contract: &DataContract, + hashtag: &str, + select: SelectProjection, + ) -> SdkDocumentQuery { + SdkDocumentQuery::new(Arc::new(contract.clone()), DOCUMENT_TYPE) + .expect("the fixture has this document type") + .with_select(select) + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(hashtag.to_string()), + }) + .with_time_range(CREATED_AT, TimeRangeSelector::Newest) + } + + /// Wrap a handler proof and its metadata the way the wire does, so the + /// entry points consume exactly what a node returns. + fn signed_response(proof: Proof, mtd: &ResponseMetadata) -> GetDocumentsResponse { + GetDocumentsResponse { + version: Some(ResponseVersion::V1(GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Proof(proof)), + metadata: Some(mtd.clone()), + })), + } + } + + fn select_field(function: v1_select::Function, field: &str) -> Vec { + vec![V1Select { + function: function as i32, + field: field.to_string(), + }] + } + + /// Nudge the signed time one full step so the entry point's own + /// resolution lands on a different bucket than the proof covers. + fn one_step_later( + contract: &DataContract, + index: &str, + mtd: &ResponseMetadata, + ) -> ResponseMetadata { + let step_ms = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists") + .indexes() + .get(index) + .expect("the bucketed index survives contract registration") + .time_range + .as_ref() + .expect("the bucketed index carries its transform") + .step_ms(); + let mut tampered = mtd.clone(); + tampered.time_ms += step_ms; + tampered + } + + fn assert_proof_or_signature_rejection(error: ProofVerifierError) { + assert!( + matches!( + error, + ProofVerifierError::InvalidSignature { .. } + | ProofVerifierError::GroveDBError { .. } + | ProofVerifierError::DriveError { .. } + ), + "the rejection must be the proof or the signature binding, got: {error:?}" + ); + } + + #[test] + fn a_count_proof_verifies_through_the_sdk_from_proof_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let query = sdk_query(&contract, "ibiza", SelectProjection::count_star()); + let (count, verified_mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &mtd), + Network::Testnet, + version, + &provider, + ) + .expect("a correctly signed count must verify through the entry point"); + + assert_eq!( + count.expect("the newest bucket is not empty"), + DocumentCount(2), + "the two #ibiza posts in the newest bucket count once each through \ + the entry point, exactly as through the step-by-step sequence" + ); + assert_eq!( + verified_mtd.time_ms, BLOCK_TIME_MS, + "the metadata handed back is the one the resolution consumed" + ); + } + + #[test] + fn a_tampered_metadata_time_is_rejected_at_the_count_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + let tampered = one_step_later(&contract, BUCKETED_INDEX, &mtd); + + let query = sdk_query(&contract, "ibiza", SelectProjection::count_star()); + let error = >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &tampered), + Network::Testnet, + version, + &provider, + ) + .expect_err("an altered signed time must not yield a verified count"); + assert_proof_or_signature_rejection(error); + } + + #[test] + fn a_sum_proof_verifies_through_the_sdk_from_proof_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_engagement(&platform, &base_state, version); + + let request = GetDocumentsRequestV1 { + selects: select_field(v1_select::Function::Sum, "likes"), + ..trending_request(contract.id().to_vec(), "ibiza", Vec::new()) + }; + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let query = sdk_query(&contract, "ibiza", SelectProjection::sum("likes")); + let (sum, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &mtd), + Network::Testnet, + version, + &provider, + ) + .expect("a correctly signed sum must verify through the entry point"); + + assert_eq!( + sum.expect("the newest bucket is not empty"), + DocumentSum(40), + "10 + 30 likes on the two newest-bucket #ibiza posts — the older \ + #ibiza post's 100 and the #berlin post's 7 must not leak in, and \ + overlap fan-out must not multiply the total" + ); + } + + #[test] + fn a_tampered_metadata_time_is_rejected_at_the_sum_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_engagement(&platform, &base_state, version); + + let request = GetDocumentsRequestV1 { + selects: select_field(v1_select::Function::Sum, "likes"), + ..trending_request(contract.id().to_vec(), "ibiza", Vec::new()) + }; + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + let tampered = one_step_later(&contract, SUMMABLE_INDEX, &mtd); + + let query = sdk_query(&contract, "ibiza", SelectProjection::sum("likes")); + let error = >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &tampered), + Network::Testnet, + version, + &provider, + ) + .expect_err("an altered signed time must not yield a verified sum"); + assert_proof_or_signature_rejection(error); + } + + #[test] + fn an_average_proof_verifies_through_the_sdk_from_proof_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_engagement(&platform, &base_state, version); + + let request = GetDocumentsRequestV1 { + selects: select_field(v1_select::Function::Avg, "likes"), + ..trending_request(contract.id().to_vec(), "ibiza", Vec::new()) + }; + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let query = sdk_query(&contract, "ibiza", SelectProjection::avg("likes")); + let (average, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &mtd), + Network::Testnet, + version, + &provider, + ) + .expect("a correctly signed average must verify through the entry point"); + + let average = average.expect("the newest bucket is not empty"); + assert_eq!( + average, + DocumentAverage { count: 2, sum: 40 }, + "the verified pair is the newest bucket's two #ibiza posts and \ + their 40 likes — nothing from outside the bucket, nothing doubled" + ); + assert_eq!(average.as_f64(), Some(20.0)); + } + + #[test] + fn a_tampered_metadata_time_is_rejected_at_the_average_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_engagement(&platform, &base_state, version); + + let request = GetDocumentsRequestV1 { + selects: select_field(v1_select::Function::Avg, "likes"), + ..trending_request(contract.id().to_vec(), "ibiza", Vec::new()) + }; + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + let tampered = one_step_later(&contract, SUMMABLE_INDEX, &mtd); + + let query = sdk_query(&contract, "ibiza", SelectProjection::avg("likes")); + let error = + >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &tampered), + Network::Testnet, + version, + &provider, + ) + .expect_err("an altered signed time must not yield a verified average"); + assert_proof_or_signature_rejection(error); + } } From 550eed37ef1c84e56aec77ad7407b5563889fa86 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 20:04:12 +0200 Subject: [PATCH 03/20] feat!: phase-only time-range grids with grid-qualified index levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two design changes to the timeRange transform, both free while PV14 is unreleased: Phase replaces origin. The alignment parameter is now a pure phase offset — contract key `phase`, validated strictly less than `step` — so the grid covers all of time and every value above one step, being a redundant spelling of phase % step, is rejected rather than normalized. The old origin's beginning-of-time role (documents before it were unindexed, earlier selectors refused) is gone with it; only the sub-step epoch sliver before the phase anchor stays outside every window, defensively, since no real timestamp reaches it. Grid-qualified index levels. A transformed first property's index level is keyed by the property name qualified with its grid — `$createdAt#21600#7200`, phase appended iff non-zero, seconds verbatim from the contract, single-sourced in TimeRangeTransform::storage_key — instead of the bare name. Different grids fork into sibling subtrees, so one timestamp may now be bucketed by several grids at once (every 6h start is also a 3h start; unqualified they interleaved in one keyspace, which is why the identical-transform-or-none rule existed — now deleted). Identical grids still share a level. Contract setup, the document walkers, query path derivation, the uniqueness probe and proof verification all derive the segment through one function. Queries follow: resolution provenance now carries the exact grid (ResolvedTimeRange replaces the bare field list), so index selection pins to the resolved grid's index and one grid's bucket start can never verify against another's. The IN_TIME_RANGE operand grows a structured list form [selector, range, step(, phase)] naming a grid — required on multi-grid fields, where the bare text selector is now refused as ambiguous; zero phase is spelled by omission on the wire too, one spelling per grid everywhere. No proto message changes: the list rides the existing DocumentFieldValue.ValueList. New coverage: phase parse/validation and the removed-origin rejection; grid-qualified storage keys; a two-grid e2e proving fan-out, per-grid reads, cross-grid isolation on a shared numeric bucket start, and delete-from-both; resolver disambiguation (ambiguous bare selector, exact-grid match, unknown-grid refusal); and wire-level tests proving each grid independently through the SDK FromProof entry points. Co-Authored-By: Claude Fable 5 --- .../platform/v0/objective-c/Platform.pbobjc.h | 12 +- .../protos/platform/v0/platform.proto | 12 +- .../src/documents/average_proof_helpers.rs | 8 +- .../src/documents/count_proof_helpers.rs | 51 ++- .../src/documents/document_query.rs | 161 ++++++-- .../src/documents/sum_proof_helpers.rs | 8 +- .../document/v3/document-meta.json | 6 +- .../try_from_schema/common/mod.rs | 42 +- .../data_contract/document_type/index/mod.rs | 152 +++++++- .../document_type/index/time_range.rs | 221 +++++++---- .../index_level/find_first_change.rs | 4 +- .../document_type/index_level/mod.rs | 47 +-- .../v0/mod.rs | 2 +- .../data_triggers/triggers/dpns/v0/mod.rs | 4 +- .../data_triggers/triggers/dpns/v1/mod.rs | 4 +- .../triggers/withdrawals/v0/mod.rs | 2 +- .../triggers/withdrawals/v1/mod.rs | 2 +- .../batch/state/v0/fetch_documents.rs | 8 +- .../batch/tests/document/dpns.rs | 18 +- .../data_contract_update/mod.rs | 4 +- .../src/query/document_query/v0/mod.rs | 21 +- .../query/document_query/v1/conversions.rs | 114 +++++- .../document_query/v1/dispatch/average.rs | 5 +- .../query/document_query/v1/dispatch/count.rs | 5 +- .../document_query/v1/dispatch/documents.rs | 5 +- .../document_query/v1/dispatch/ranked.rs | 5 +- .../query/document_query/v1/dispatch/sum.rs | 5 +- .../src/query/document_query/v1/mod.rs | 34 +- .../src/query/document_query/v1/tests.rs | 243 +++++++++++- .../tests/vectors_documents.rs | 2 +- .../benches/document_count_worst_case.rs | 2 +- .../benches/document_sum_worst_case.rs | 2 +- .../v1/mod.rs | 20 +- .../contract/insert/insert_contract/v0/mod.rs | 25 +- .../v0/tests/batched_group_drain.rs | 2 +- .../tests/range_countable_index_e2e_tests.rs | 2 +- .../v0/tests/ranked_index_e2e_tests.rs | 4 +- .../contract/update/update_contract/v0/mod.rs | 41 +- .../v2/mod.rs | 18 +- .../validate_uniqueness_of_data/v0/mod.rs | 2 +- .../validate_uniqueness_of_data/v1/mod.rs | 24 +- .../drive/document/index_uniqueness/mod.rs | 63 +-- .../insert/add_document_for_contract/mod.rs | 363 +++++++++++++++++- .../v2/mod.rs | 18 +- .../v1/mod.rs | 40 +- .../rs-drive/src/drive/document/update/mod.rs | 4 +- .../v0/mod.rs | 4 +- .../v0/mod.rs | 2 +- .../v1/mod.rs | 2 +- .../drive_dispatcher.rs | 66 ++-- .../query/drive_document_average_query/mod.rs | 5 +- .../drive_dispatcher.rs | 10 +- .../executors/per_in_value.rs | 5 +- .../executors/range_no_proof.rs | 5 +- .../executors/total.rs | 5 +- .../drive_dispatcher.rs | 23 +- .../executors/per_in_value.rs | 5 +- .../executors/point_lookup_proof.rs | 5 +- .../range_aggregate_carrier_proof.rs | 5 +- .../executors/range_distinct_proof.rs | 5 +- .../executors/range_no_proof.rs | 5 +- .../executors/range_proof.rs | 5 +- .../executors/total.rs | 5 +- .../index_picker.rs | 13 +- .../drive_document_count_query/path_query.rs | 42 +- .../query/drive_document_count_query/tests.rs | 32 +- .../drive_dispatcher.rs | 7 +- .../drive_document_ranked_query/tests.rs | 6 +- .../drive_dispatcher.rs | 18 +- .../executors/per_in_value.rs | 5 +- .../executors/point_lookup_proof.rs | 5 +- .../range_aggregate_carrier_proof.rs | 5 +- .../executors/range_distinct_proof.rs | 5 +- .../executors/range_no_proof.rs | 5 +- .../executors/range_proof.rs | 5 +- .../executors/total.rs | 5 +- .../drive_document_sum_query/index_picker.rs | 13 +- .../src/query/drive_document_sum_query/mod.rs | 5 +- .../drive_document_sum_query/path_query.rs | 73 ++-- .../query/drive_document_sum_query/tests.rs | 6 +- packages/rs-drive/src/query/mod.rs | 262 +++++++++---- .../multiple_in_path_query/v0/mod.rs | 13 +- .../single_in_path_query/v0/mod.rs | 12 +- .../src/verify/document/verify_proof/mod.rs | 2 +- .../verify_proof_keep_serialized/mod.rs | 2 +- .../verify_start_at_document_in_proof/mod.rs | 2 +- packages/rs-drive/tests/query_tests.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 23 +- packages/rs-sdk/tests/fetch/document.rs | 2 +- .../src/document/verify_proof.rs | 2 +- .../document/verify_proof_keep_serialized.rs | 2 +- .../verify_start_at_document_in_proof.rs | 2 +- 92 files changed, 1854 insertions(+), 723 deletions(-) diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h index 96f6b2ca369..7ac2d869d93 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h @@ -389,9 +389,15 @@ typedef GPB_ENUM(GetDocumentsRequest_WhereOperator) { /** * Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the * v0 CBOR where surface is unaffected). The clause's `field` names a - * timestamp property covered by a `timeRange` index; the operand - * (`DocumentFieldValue.text`) is the selector `"newest"` or `"oldest"`. - * The server resolves it to a concrete equality on the bucket start + * timestamp property covered by a `timeRange` index. The operand is + * either `DocumentFieldValue.text` — the bare selector `"newest"` or + * `"oldest"`, legal while exactly one grid buckets the field — or + * `DocumentFieldValue.list` of `[text(selector), uint64(range), + * uint64(step)]` / `[…, uint64(phase)]`, naming one of the field's + * grids in the contract's own declared seconds (required when several + * grids bucket the field; a zero phase is spelled by omission, so + * every grid has exactly one wire spelling). The server resolves the + * selector to a concrete equality on the named grid's bucket start * using the current block time, and the verifier re-derives the same * bucket from the quorum-signed response metadata time — so the proof * is an ordinary index/count proof. See `timeRange` in the document diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index b3527de13bc..cb361c01c14 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -596,9 +596,15 @@ message GetDocumentsRequest { STARTS_WITH = 10; // Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the // v0 CBOR where surface is unaffected). The clause's `field` names a - // timestamp property covered by a `timeRange` index; the operand - // (`DocumentFieldValue.text`) is the selector `"newest"` or `"oldest"`. - // The server resolves it to a concrete equality on the bucket start + // timestamp property covered by a `timeRange` index. The operand is + // either `DocumentFieldValue.text` — the bare selector `"newest"` or + // `"oldest"`, legal while exactly one grid buckets the field — or + // `DocumentFieldValue.list` of `[text(selector), uint64(range), + // uint64(step)]` / `[…, uint64(phase)]`, naming one of the field's + // grids in the contract's own declared seconds (required when several + // grids bucket the field; a zero phase is spelled by omission, so + // every grid has exactly one wire spelling). The server resolves the + // selector to a concrete equality on the named grid's bucket start // using the current block time, and the verifier re-derives the same // bucket from the quorum-signed response metadata time — so the proof // is an ordinary index/count proof. See `timeRange` in the document diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index d44ab1afeb6..72d526b5e51 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -115,7 +115,7 @@ pub(super) fn verify_average_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. - let resolved_time_range_fields = + let resolved_time_ranges = super::document_query::resolve_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, @@ -127,7 +127,7 @@ pub(super) fn verify_average_query( // honest prover, so reject before mode detection can route on it. drive::query::validate_resolved_time_range_clause_shapes( &request.where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, ) .map_err(|e| drive_proof_verifier::Error::RequestError { error: format!("invalid time range query shape: {}", e), @@ -198,7 +198,7 @@ pub(super) fn verify_average_query( document_type.indexes(), &request.where_clauses, &sum_property, - &resolved_time_range_fields, + &resolved_time_ranges, ) .filter(|idx| idx.range_countable) .ok_or_else(|| drive_proof_verifier::Error::RequestError { @@ -214,7 +214,7 @@ pub(super) fn verify_average_query( document_type.indexes(), &request.where_clauses, &sum_property, - &resolved_time_range_fields, + &resolved_time_ranges, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| drive_proof_verifier::Error::RequestError { diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index 8a942ca1fb3..999f5167c9f 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -155,7 +155,7 @@ pub(super) fn verify_count_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. - let resolved_time_range_fields = + let resolved_time_ranges = super::document_query::resolve_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, @@ -167,7 +167,7 @@ pub(super) fn verify_count_query( // honest prover, so reject before mode detection can route on it. drive::query::validate_resolved_time_range_clause_shapes( &request.where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, ) .map_err(|e| drive_proof_verifier::Error::RequestError { error: format!("invalid time range query shape: {}", e), @@ -248,7 +248,7 @@ pub(super) fn verify_count_query( DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &request.where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "range count requires a `range_countable: true` index whose last \ @@ -259,7 +259,7 @@ pub(super) fn verify_count_query( DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &request.where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove count requires a `countable: true` index whose properties \ @@ -475,9 +475,13 @@ mod tests { const RANGE_SECONDS: u64 = 6 * 3_600; const STEP_SECONDS: u64 = 2 * 3_600; const STEP_MS: u64 = STEP_SECONDS * 1_000; - /// An exact multiple of the two-hour step, so on the `origin: 0` grid it + /// An exact multiple of the two-hour step, so on the `phase: 0` grid it /// is itself a bucket start. const BUCKET_START_MS: u64 = 1_755_000_000_000; + /// A one-hour phase — strictly less than the step, as validation + /// requires — for the epoch-sliver refusal test. + const PHASE_SECONDS: u64 = 3_600; + const PHASE_MS: u64 = PHASE_SECONDS * 1_000; fn platform_version() -> &'static PlatformVersion { PlatformVersion::latest() @@ -519,10 +523,10 @@ mod tests { } /// A contract whose `post` doctype carries a `countable` bucketed index - /// over `(timeRange($createdAt), hashtag)`. `origin_seconds` is a - /// parameter because the pre-origin refusal is one of the behaviours + /// over `(timeRange($createdAt), hashtag)`. `phase_seconds` is a + /// parameter because the epoch-sliver refusal is one of the behaviours /// under test. - fn trending_contract(origin_seconds: u64) -> Arc { + fn trending_contract(phase_seconds: u64) -> Arc { let schemas = platform_value!({ "post": { "type": "object", @@ -538,7 +542,7 @@ mod tests { "on": "$createdAt", "range": RANGE_SECONDS, "step": STEP_SECONDS, - "origin": origin_seconds, + "phase": phase_seconds, }, }, ], @@ -603,11 +607,17 @@ mod tests { let mut request = newest_bucket_query(Arc::clone(&contract)); let metadata_time_ms = BUCKET_START_MS + 3_600_000; - let resolved_fields = + let resolutions = resolve_time_range_clauses_with_metadata_time(&mut request, metadata_time_ms) .expect("a metadata time inside an active range resolves"); - assert_eq!(resolved_fields, vec![CREATED_AT.to_string()]); + assert_eq!(resolutions.len(), 1); + assert_eq!(resolutions[0].field, CREATED_AT); + assert_eq!( + resolutions[0].transform.range_seconds, RANGE_SECONDS, + "the provenance must carry the exact grid the resolution used" + ); + assert_eq!(resolutions[0].transform.step_seconds, STEP_SECONDS); assert!( request.time_range_clauses.is_empty(), "the pending selector must be drained, not left to be encoded twice" @@ -657,24 +667,23 @@ mod tests { ); } - /// A metadata time before the index's origin belongs to no range at - /// all. The client refuses rather than inventing a bucket, mirroring the + /// A metadata time inside the epoch sliver before the grid's phase + /// anchor belongs to no range at all. No real block time reaches it, but + /// the client must refuse rather than invent a bucket, mirroring the /// server, which refuses the same request at resolution time — so the /// two sides cannot disagree about whether the query was answerable. #[test] - fn a_metadata_time_before_the_index_origin_refuses_to_resolve() { - let origin_seconds = BUCKET_START_MS / 1_000; - let contract = trending_contract(origin_seconds); + fn a_metadata_time_in_the_epoch_sliver_refuses_to_resolve() { + let contract = trending_contract(PHASE_SECONDS); let mut request = newest_bucket_query(contract); - let error = - resolve_time_range_clauses_with_metadata_time(&mut request, BUCKET_START_MS - 1) - .expect_err("a time predating every range has no honest bucket"); + let error = resolve_time_range_clauses_with_metadata_time(&mut request, PHASE_MS - 1) + .expect_err("a time predating every range has no honest bucket"); match error { drive_proof_verifier::Error::RequestError { error } => assert!( - error.contains("origin"), - "expected the pre-origin refusal, got: {error}" + error.contains("phase"), + "expected the epoch-sliver refusal, got: {error}" ), other => panic!("expected a request error, got: {other:?}"), } diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index e281889e94a..4eda618351a 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -34,13 +34,31 @@ use drive::config::DEFAULT_QUERY_LIMIT; use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key; use drive::query::{ DriveDocumentQuery, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, - HavingRightOperand, InternalClauses, OrderClause, SelectFunction, SelectProjection, - TimeRangeSelector, WhereClause, WhereOperator, + HavingRightOperand, InternalClauses, OrderClause, ResolvedTimeRange, SelectFunction, + SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, WhereOperator, }; use drive_proof_verifier::{types::Documents, FromProof}; // TODO: remove DocumentQuery once ContextProvider that provides data contracts is merged. +/// One pending `IN_TIME_RANGE` selection: the timestamp field, the +/// `"newest"` / `"oldest"` selector, and — when the contract buckets the +/// field with more than one `timeRange` grid — the grid it targets (`None` +/// means the field's sole grid, and the server rejects the bare form on a +/// multi-grid field as ambiguous). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub struct TimeRangeClause { + /// The bucketed timestamp field the selection is on. + pub field: String, + /// Which active window to resolve to. + pub selector: TimeRangeSelector, + /// The grid targeted, in the contract's declared seconds; `None` when + /// the field carries a single grid. + #[cfg_attr(feature = "mocks", serde(default))] + pub grid: Option, +} + /// Request that is used to query documents from the Dash Platform. /// /// This is an abstraction layer built on top of [GetDocumentsRequest] to address issues with missing details @@ -74,14 +92,15 @@ pub struct DocumentQuery { pub document_type_name: String, /// `where` clauses for the query pub where_clauses: Vec, - /// Time-range (`IN_TIME_RANGE`) selections — `(field, selector)` pairs on - /// a timestamp field covered by a `timeRange` index. These are emitted as - /// `IN_TIME_RANGE` clauses on the v1 wire and resolved server-side from - /// the current block time; the verifier re-derives the same bucket from - /// the quorum-signed response metadata time. v1-only (the v0 wire has no - /// `IN_TIME_RANGE` operator). See [`Self::with_time_range`]. + /// Time-range (`IN_TIME_RANGE`) selections on a timestamp field covered + /// by a `timeRange` index. These are emitted as `IN_TIME_RANGE` clauses + /// on the v1 wire and resolved server-side from the current block time; + /// the verifier re-derives the same bucket from the quorum-signed + /// response metadata time. v1-only (the v0 wire has no `IN_TIME_RANGE` + /// operator). See [`Self::with_time_range`] and + /// [`Self::with_time_range_grid`]. #[cfg_attr(feature = "mocks", serde(default))] - pub time_range_clauses: Vec<(String, TimeRangeSelector)>, + pub time_range_clauses: Vec, /// SQL `GROUP BY` field names, in left-to-right order. Empty = /// no explicit grouping (aggregate count for `select=Count`). /// Only meaningful when `select=Count`; non-empty with @@ -226,13 +245,41 @@ impl DocumentQuery { /// re-derives the identical bucket from the quorum-signed response /// metadata time. Requires Platform v3.1+ (v1 wire). /// + /// The bare selector is unambiguous only while exactly one grid buckets + /// `field`; when the contract declares several grids over it, use + /// [`Self::with_time_range_grid`] to name one. + /// /// Existing time-range selections are preserved. pub fn with_time_range( mut self, field: impl Into, selector: TimeRangeSelector, ) -> Self { - self.time_range_clauses.push((field.into(), selector)); + self.time_range_clauses.push(TimeRangeClause { + field: field.into(), + selector, + grid: None, + }); + self + } + + /// [`Self::with_time_range`] naming a specific grid — required when the + /// contract buckets `field` with more than one `timeRange` grid. The + /// spec's `range` / `step` / `phase` are the contract's own declared + /// seconds, verbatim. + /// + /// Existing time-range selections are preserved. + pub fn with_time_range_grid( + mut self, + field: impl Into, + selector: TimeRangeSelector, + grid: TimeRangeGridSpec, + ) -> Self { + self.time_range_clauses.push(TimeRangeClause { + field: field.into(), + selector, + grid: Some(grid), + }); self } @@ -480,7 +527,7 @@ impl FromProof for drive_proof_verifier::types::Documents { // reconstructed query matches the proof exactly. Resolve before the // `DriveDocumentQuery` conversion so the engine sees ordinary equality // clauses. - let mut resolved_time_range_fields = Vec::new(); + let mut resolved_time_ranges = Vec::new(); if !request.time_range_clauses.is_empty() { // The generated `VersionedGrpcResponse::metadata()` handles both // response envelopes (and any future one), so no hand-written @@ -493,7 +540,7 @@ impl FromProof for drive_proof_verifier::types::Documents { error: "time range query proof response is missing block-time metadata" .to_string(), })?; - resolved_time_range_fields = + resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(&mut request, time_ms)?; } @@ -506,7 +553,7 @@ impl FromProof for drive_proof_verifier::types::Documents { // The conversion cannot recover which equalities came from resolution, // so the provenance is carried across here; index selection reads it // to pin the query to the index that buckets the field. - drive_query.resolved_time_range_fields = resolved_time_range_fields; + drive_query.resolved_time_ranges = resolved_time_ranges; >::maybe_from_proof_with_metadata( drive_query, @@ -532,16 +579,18 @@ impl FromProof for drive_proof_verifier::types::Documents { /// this before resolving their mode. Skipping it would rebuild the query from /// a different shape than the prover used. /// -/// Returns the resolved fields — the names whose pushed clause is a bucket -/// equality rather than a raw-timestamp one. Callers must carry them into -/// index selection (`DriveDocumentQuery::resolved_time_range_fields`, or the -/// `resolved_time_range_fields` argument of the aggregate index pickers): +/// Returns the resolution provenance — one [`ResolvedTimeRange`] (field + +/// exact grid) per selection, whose pushed clause is a bucket equality +/// rather than a raw-timestamp one. Callers must carry them into index +/// selection (`DriveDocumentQuery::resolved_time_ranges`, or the +/// `resolved_time_ranges` argument of the aggregate index pickers): /// the pushed clause is an ordinary equality and nothing downstream can -/// otherwise tell that it must be matched against bucket starts. +/// otherwise tell that it must be matched against the resolved grid's +/// bucket starts. pub(super) fn resolve_time_range_clauses_with_metadata_time( request: &mut DocumentQuery, time_ms: u64, -) -> Result, drive_proof_verifier::Error> { +) -> Result, drive_proof_verifier::Error> { if request.time_range_clauses.is_empty() { return Ok(Vec::new()); } @@ -552,21 +601,27 @@ pub(super) fn resolve_time_range_clauses_with_metadata_time( error: format!("document type not found for time range query: {}", e), })?; let time_range_clauses = std::mem::take(&mut request.time_range_clauses); - let mut resolved_fields = Vec::with_capacity(time_range_clauses.len()); - for (field, selector) in time_range_clauses { - let resolved = drive::query::resolve_time_range_bucket_clause( + let mut resolved_time_ranges = Vec::with_capacity(time_range_clauses.len()); + for TimeRangeClause { + field, + selector, + grid, + } in time_range_clauses + { + let (clause, resolved) = drive::query::resolve_time_range_bucket_clause( &field, selector, + grid, document_type, time_ms, ) .map_err(|e| drive_proof_verifier::Error::RequestError { error: format!("failed to resolve time range clause: {}", e), })?; - request.where_clauses.push(resolved); - resolved_fields.push(field); + request.where_clauses.push(clause); + resolved_time_ranges.push(resolved); } - Ok(resolved_fields) + Ok(resolved_time_ranges) } /// Version-aware encoder. The dispatch is driven by the @@ -661,7 +716,7 @@ fn encode_v1( data_contract_id: Vec, document_type: String, where_clauses: Vec, - time_range_clauses: Vec<(String, TimeRangeSelector)>, + time_range_clauses: Vec, order_by_clauses: Vec, limit: u32, offset: Option, @@ -674,19 +729,51 @@ fn encode_v1( .into_iter() .map(where_clause_to_proto) .collect::, _>>()?; - // Append time-range selections as `IN_TIME_RANGE` clauses: field + - // `"newest"`/`"oldest"` text operand. The server resolves them to a - // concrete bucket from current block time; the verifier re-derives the - // same bucket from the signed response metadata time. - for (field, selector) in time_range_clauses { + // Append time-range selections as `IN_TIME_RANGE` clauses. A grid-less + // selection rides as the bare `"newest"`/`"oldest"` text operand; a + // grid-targeted one as the list operand `[selector, range, step]` / + // `[selector, range, step, phase]` in the contract's declared seconds + // (zero phase spelled by omission — one wire spelling per grid, the + // same rule the contract grammar and the storage key follow). The + // server resolves them to a concrete bucket from current block time; + // the verifier re-derives the same bucket from the signed response + // metadata time. + for TimeRangeClause { + field, + selector, + grid, + } in time_range_clauses + { + let selector_value = ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Text( + selector.as_str().to_string(), + )), + }; + let operand = match grid { + None => selector_value, + Some(spec) => { + let uint = |n: u64| ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Uint64Value(n)), + }; + let mut values = vec![ + selector_value, + uint(spec.range_seconds), + uint(spec.step_seconds), + ]; + if spec.phase_seconds != 0 { + values.push(uint(spec.phase_seconds)); + } + ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::List( + document_field_value::ValueList { values }, + )), + } + } + }; where_clauses.push(ProtoWhereClause { field, operator: ProtoWhereOperator::InTimeRange as i32, - value: Some(ProtoDocumentFieldValue { - variant: Some(document_field_value::Variant::Text( - selector.as_str().to_string(), - )), - }), + value: Some(operand), }); } let order_by = order_by_clauses @@ -1029,7 +1116,7 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { // its equalities came from resolution. Callers that resolved // selections assign the fields they resolved onto the returned // query; everything else is a raw query. - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; Ok(query) diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index b974d84d79c..c05d77ae7a3 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -108,7 +108,7 @@ pub(super) fn verify_sum_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. - let resolved_time_range_fields = + let resolved_time_ranges = super::document_query::resolve_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, @@ -120,7 +120,7 @@ pub(super) fn verify_sum_query( // honest prover, so reject before mode detection can route on it. drive::query::validate_resolved_time_range_clause_shapes( &request.where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, ) .map_err(|e| drive_proof_verifier::Error::RequestError { error: format!("invalid time range query shape: {}", e), @@ -195,7 +195,7 @@ pub(super) fn verify_sum_query( document_type.indexes(), &request.where_clauses, &sum_property, - &resolved_time_range_fields, + &resolved_time_ranges, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove range SUM requires a `rangeSummable: true` index whose last \ @@ -208,7 +208,7 @@ pub(super) fn verify_sum_query( document_type.indexes(), &request.where_clauses, &sum_property, - &resolved_time_range_fields, + &resolved_time_ranges, ) .ok_or_else(|| drive_proof_verifier::Error::RequestError { error: "prove SUM requires a `summable: \"\"` index whose properties \ diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index f8f2c1666d6..de02a70dbdb 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -613,15 +613,15 @@ "minimum": 1, "description": "Interval between successive range starts, in seconds. When `range` > `step` the ranges overlap and a document is indexed under `range / step` bucket-start values, bounded by a protocol-versioned cap (24 at protocol version 14)." }, - "origin": { + "phase": { "type": "integer", "minimum": 0, - "description": "Reference origin for range alignment, in seconds. Range starts are `origin + k * step`. Defaults to 0." + "description": "Grid alignment phase, in seconds. Range starts are `phase + k * step`; must be strictly less than `step` (a larger value would be a redundant spelling of `phase % step`). A pure alignment offset — it moves where window boundaries fall (e.g. daily windows cut at 06:00 UTC instead of midnight) and never excludes any timestamp. Defaults to 0." } }, "required": ["on", "range", "step"], "additionalProperties": false, - "description": "Buckets the first index property's timestamp into fixed-length, regularly-spaced (possibly overlapping) time ranges. The window parameters (`range`, `step`, `origin`) are declared in seconds, since a bucket is selected from block time and the target block interval is five seconds; the stored key is the range start as a u64 millisecond timestamp, so it stays directly comparable to the source timestamp it buckets. Enables trending/leaderboard queries within the newest/oldest active range. A system-timestamp source must be listed in the document type's required fields, and documents whose timestamp predates `origin` belong to no range (they are absent from this index). Available from protocol version 14." + "description": "Buckets the first index property's timestamp into fixed-length, regularly-spaced (possibly overlapping) time ranges. The window parameters (`range`, `step`, `phase`) are declared in seconds, since a bucket is selected from block time and the target block interval is five seconds; the stored key is the range start as a u64 millisecond timestamp, so it stays directly comparable to the source timestamp it buckets. Enables trending/leaderboard queries within the newest/oldest active range. A system-timestamp source must be listed in the document type's required fields. Several indexes may bucket the same timestamp with different grids — each grid gets its own index subtree, keyed by the property name qualified with the grid parameters. Available from protocol version 14." } }, "required": [ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 067340dccf1..c695427710e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -22,8 +22,6 @@ use crate::data_contract::config::v0::DataContractConfigGettersV0; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::consensus_or_protocol_value_error; use crate::data_contract::document_type::index::Index; -#[cfg(feature = "validation")] -use crate::data_contract::document_type::index::TimeRangeTransform; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::document_type::property::DocumentPropertyType; @@ -1078,39 +1076,13 @@ fn parse_indices( // core never branches on a version. (ctx.generation.ranked_index_structure_check)(&indices)?; - // TIME RANGE: all indices that share a first property must agree on its - // time-range transform: either every such index buckets it with the - // identical transform, or none do. Otherwise the merged index trie node - // for that first property would be ambiguous (bucketed for one index, - // plain for another), so we reject the contract up front. No-op for - // generations whose grammar has no `timeRange`. - #[cfg(feature = "validation")] - if ctx.full_validation { - let mut first_property_time_range: BTreeMap<&str, Option<&TimeRangeTransform>> = - BTreeMap::new(); - for index in indices.values() { - let Some(first) = index.properties.first() else { - continue; - }; - let transform = index.time_range.as_ref(); - match first_property_time_range.get(first.name.as_str()) { - Some(existing) if *existing != transform => { - return Err(consensus_or_protocol_data_contract_error( - DataContractError::InvalidContractStructure(format!( - "indices that share the first property \"{}\" must agree on its \ - timeRange transform: either all bucket it identically or none \ - do", - first.name - )), - )); - } - Some(_) => {} - None => { - first_property_time_range.insert(first.name.as_str(), transform); - } - } - } - } + // TIME RANGE: indices that share a first property may bucket it with + // different grids (or not at all) — each grid forks into its own index + // level, keyed by the property name qualified with the grid parameters + // (`TimeRangeTransform::storage_key`), so a bucketed level never shares + // a keyspace with a plain level or with another grid's level. No + // cross-index agreement rule is needed; identical grids simply share + // one level. let index_structure = IndexLevel::try_from_indices(indices.values(), ctx.name, ctx.platform_version)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 719b73506d3..97502136c56 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -527,6 +527,37 @@ impl Index { .collect() } + /// The GroveDB index-level key for `property_name` at `position` in this + /// index's property list. The first property of a time-range index is + /// keyed by [`TimeRangeTransform::storage_key`] — the name qualified with + /// the grid, so several grids over one timestamp fork into sibling + /// subtrees; every other property keeps its bare name. + /// + /// Every place that turns this index's properties into GroveDB path + /// segments — contract setup, the document walkers (via `IndexLevel`, + /// which stores these same keys), query path derivation, the uniqueness + /// probe and proof verification — must derive the segment through this + /// rule, or one logical index splits into two trees. + pub fn level_key(&self, position: usize, property_name: &str) -> String { + match (&self.time_range, position) { + (Some(transform), 0) => transform.storage_key(property_name), + _ => property_name.to_string(), + } + } + + /// Name-keyed variant of [`Self::level_key`] for callers that hold a + /// property name rather than its position: the transform's source is + /// validated to be the index's first property, so matching the name + /// against `time_range.source` identifies the grid-qualified level. + pub fn level_key_for_property(&self, property_name: &str) -> String { + match &self.time_range { + Some(transform) if transform.source == property_name => { + transform.storage_key(property_name) + } + _ => property_name.to_string(), + } + } + /// Get values pub fn extract_values(&self, data: &BTreeMap) -> Vec { self.properties @@ -1020,7 +1051,7 @@ impl Index { let mut source: Option = None; let mut range_seconds: Option = None; let mut step_seconds: Option = None; - let mut origin_seconds: u64 = 0; + let mut phase_seconds: u64 = 0; for (tr_key_value, tr_value) in time_range_map { let tr_key = tr_key_value @@ -1051,10 +1082,10 @@ impl Index { ) })?); } - "origin" => { - origin_seconds = tr_value.to_integer().map_err(|_| { + "phase" => { + phase_seconds = tr_value.to_integer().map_err(|_| { DataContractError::ValueWrongType( - "timeRange.origin should be an integer".to_string(), + "timeRange.phase should be an integer".to_string(), ) })?; } @@ -1087,7 +1118,7 @@ impl Index { source, range_seconds, step_seconds, - origin_seconds, + phase_seconds, }); } "properties" => { @@ -1437,7 +1468,7 @@ impl Index { for (field, seconds) in [ ("range", transform.range_seconds), ("step", transform.step_seconds), - ("origin", transform.origin_seconds), + ("phase", transform.phase_seconds), ] { if seconds > u64::MAX / 1_000 { return Err(DataContractError::InvalidContractStructure(format!( @@ -1451,6 +1482,20 @@ impl Index { "timeRange.step must be greater than zero".to_string(), )); } + // The phase is a pure alignment offset: shifting the grid by a + // whole number of steps reproduces the identical grid, so any + // value >= step is a second spelling of a smaller phase. One + // grid, one spelling — reject rather than normalize, so the + // contract, the transform and the storage key all carry the same + // number. + if transform.phase_seconds >= transform.step_seconds { + return Err(DataContractError::InvalidContractStructure(format!( + "timeRange.phase ({} seconds) must be less than timeRange.step ({} \ + seconds): the phase only aligns the grid within one step, and a larger \ + value would be a redundant spelling of phase % step", + transform.phase_seconds, transform.step_seconds + ))); + } if transform.range_seconds == 0 { return Err(DataContractError::InvalidContractStructure( "timeRange.range must be greater than zero".to_string(), @@ -1650,6 +1695,99 @@ mod tests { map } + /// [`index_value_map`] with one extra `timeRange` key — for the `phase` + /// tests and for pinning that removed keys (like the pre-phase-era + /// `origin`) fall through to the unknown-field rejection. + fn index_value_map_with_extra_time_range_key( + range: u64, + step: u64, + key: &str, + value: u64, + ) -> Vec<(Value, Value)> { + let mut map = index_value_map("$createdAt", Some(("$createdAt", range, step))); + let Some((_, Value::Map(time_range))) = map + .iter_mut() + .find(|(k, _)| k.as_text() == Some("timeRange")) + else { + panic!("the helper always builds a timeRange map"); + }; + time_range.push((Value::Text(key.to_string()), Value::U64(value))); + map + } + + /// `phase` parses into the transform, and its being strictly less than + /// `step` is a structural rule — a larger value is a redundant spelling + /// of `phase % step` and is rejected rather than normalized, so the + /// contract, the transform and the storage key all carry one number. + #[test] + fn time_range_phase_parses_and_must_be_less_than_step() { + let map = index_value_map_with_extra_time_range_key(21_600, 7_200, "phase", 3_600); + let index = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + let transform = index.time_range.expect("time_range should be set"); + assert_eq!(transform.phase_seconds, 3_600); + + // phase == step: one whole step of shift reproduces the identical + // grid, so this is the smallest redundant spelling. + let map = index_value_map_with_extra_time_range_key(21_600, 7_200, "phase", 7_200); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + + let map = index_value_map_with_extra_time_range_key(21_600, 7_200, "phase", 10_000); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + /// The pre-phase grammar's `origin` key no longer exists: it named an + /// absolute grid anchor with beginning-of-time semantics the phase-only + /// design dropped, so it must be rejected as an unknown field rather + /// than silently ignored. + #[test] + fn time_range_rejects_the_removed_origin_key() { + let map = index_value_map_with_extra_time_range_key(21_600, 7_200, "origin", 3_600); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + + /// Two grids over the same property produce distinct storage keys — the + /// fork that lets them coexist as sibling index subtrees — and identical + /// grids produce the identical key, so indexes sharing a grid share a + /// level. Zero phase is spelled by omission; a declared phase appends + /// the fourth part. + #[test] + fn time_range_storage_keys_are_grid_qualified() { + let map = index_value_map("$createdAt", Some(("$createdAt", 21_600, 7_200))); + let index = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + assert_eq!(index.level_key(0, "$createdAt"), "$createdAt#21600#7200"); + assert_eq!( + index.level_key_for_property("$createdAt"), + "$createdAt#21600#7200" + ); + // non-first / non-source properties keep their bare names + assert_eq!(index.level_key(1, "hashtag"), "hashtag"); + assert_eq!(index.level_key_for_property("hashtag"), "hashtag"); + + let map = index_value_map_with_extra_time_range_key(21_600, 7_200, "phase", 3_600); + let phased = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + assert_eq!( + phased.level_key(0, "$createdAt"), + "$createdAt#21600#7200#3600" + ); + assert_ne!( + phased.level_key(0, "$createdAt"), + index.level_key(0, "$createdAt"), + "different grids must fork into different levels" + ); + } + #[test] fn time_range_index_parses() { // A six-hour window refreshed every two hours. @@ -1659,7 +1797,7 @@ mod tests { assert_eq!(transform.source, "$createdAt"); assert_eq!(transform.range_seconds, 21_600); assert_eq!(transform.step_seconds, 7_200); - assert_eq!(transform.origin_seconds, 0); + assert_eq!(transform.phase_seconds, 0); assert_eq!(transform.overlap_factor(), 3); // The parsed seconds are what the bucket math scales into the // millisecond domain the source timestamps live in. diff --git a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs index f9b190b6a4f..c4f3571332e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// fixed-length, regularly-spaced time ranges. /// /// The window is **declared in seconds** and **identified in milliseconds**. -/// A contract author writes `range` / `step` / `origin` as second counts +/// A contract author writes `range` / `step` / `phase` as second counts /// because the finest clock a bucket selection ever sees is block time, whose /// target interval is five seconds — a window declared to the millisecond /// would be precision the protocol cannot deliver. A time range is still @@ -15,10 +15,13 @@ use serde::{Deserialize, Serialize}; /// comparable to them. [`Self::range_ms`] and its siblings are the one place /// the two units meet. /// -/// Each range covers `[start, start + range)`. New ranges start every `step`. -/// When `range > step` the ranges overlap, so a single timestamp falls into -/// `range / step` ranges (the "overlap factor") and a document is indexed -/// under that many bucket-start values. +/// Each range covers `[start, start + range)`. New ranges start every `step`, +/// on the grid `phase + k * step` — `phase` is a pure alignment offset, +/// validated to be strictly less than `step`, so the grid covers all of time +/// (bar the sub-`step` sliver at the epoch that no real timestamp can ever +/// fall into). When `range > step` the ranges overlap, so a single timestamp +/// falls into `range / step` ranges (the "overlap factor") and a document is +/// indexed under that many bucket-start values. /// /// The canonical use case is "trending" leaderboards: index on /// `(timeRange($createdAt), hashtag)` with `countable`, then query a single @@ -35,13 +38,15 @@ use serde::{Deserialize, Serialize}; /// within the bucket" is served as the grouped count above with client-side /// ordering until ranked prefix routing lands at a future protocol version. /// -/// This transform lives on the index definition only. At the GroveDB storage -/// layer a bucket start is an ordinary `u64` key segment (encoded exactly -/// like a `$createdAt` value), so existing index queries, count trees and -/// proofs apply unchanged — the only novelty is that one document produces -/// several index entries. +/// At the GroveDB storage layer, a transformed first property gets its own +/// index level keyed by [`Self::storage_key`] — the property name qualified +/// with the grid — so several grids over the same timestamp coexist as +/// sibling subtrees. Within a grid's subtree a bucket start is an ordinary +/// `u64` key segment (encoded exactly like a `$createdAt` value), so existing +/// index queries, count trees and proofs apply unchanged — the only novelty +/// is that one document produces several index entries. // The serde keys deliberately match the contract grammar (`on` / `range` / -// `step` / `origin`, see the `timeRange` entry in the v3 document +// `step` / `phase`, see the `timeRange` entry in the v3 document // meta-schema), so a serialized `Index` round-trips into the same key set a // contract author writes rather than a second, camelCased spelling. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -66,11 +71,14 @@ pub struct TimeRangeTransform { /// millisecond timeline. #[cfg_attr(feature = "serde-conversion", serde(rename = "step"))] pub step_seconds: u64, - /// Reference origin for range alignment, in seconds. Range starts are the - /// millisecond timestamps `origin_ms() + k * step_ms()` for - /// `k = 0, 1, 2, …`. Defaults to `0`. - #[cfg_attr(feature = "serde-conversion", serde(rename = "origin", default))] - pub origin_seconds: u64, + /// Grid alignment phase, in seconds. Range starts are the millisecond + /// timestamps `phase_ms() + k * step_ms()` for `k = 0, 1, 2, …`. A pure + /// phase offset: contract validation requires `phase < step`, so shifting + /// the grid never excludes any real timestamp — it only moves where the + /// window boundaries fall (e.g. daily windows cut at 06:00 UTC instead of + /// midnight). Defaults to `0`. + #[cfg_attr(feature = "serde-conversion", serde(rename = "phase", default))] + pub phase_seconds: u64, } impl TimeRangeTransform { @@ -102,11 +110,11 @@ impl TimeRangeTransform { self.step_seconds.saturating_mul(1_000) } - /// The alignment origin on the millisecond timeline — the first range's - /// start. See [`Self::range_ms`] for why the accessor exists and why it - /// saturates. - pub fn origin_ms(&self) -> u64 { - self.origin_seconds.saturating_mul(1_000) + /// The grid alignment phase on the millisecond timeline — the first + /// range's start. See [`Self::range_ms`] for why the accessor exists and + /// why it saturates. + pub fn phase_ms(&self) -> u64 { + self.phase_seconds.saturating_mul(1_000) } /// The number of overlapping ranges that contain any given instant, i.e. @@ -125,21 +133,57 @@ impl TimeRangeTransform { self.range_seconds / self.step_seconds } + /// The GroveDB index-level key for this grid over `property_name`: the + /// property name qualified with the grid parameters, so different grids + /// over the same timestamp fork into sibling subtrees instead of + /// colliding in one keyspace (every 6-hour bucket start is also a + /// 3-hour bucket start — unqualified, the two grids' entries would be + /// indistinguishable). + /// + /// **This is the single source of the key encoding.** Contract setup, + /// the insert/delete/update walkers, query path derivation, the + /// uniqueness probe and proof verification must all derive the level key + /// through this function; a second implementation that disagreed on any + /// detail would split one logical index into two trees. + /// + /// Format: `{property}#{range}#{step}` with `#{phase}` appended **iff** + /// the phase is non-zero — omitted-means-zero is canonical (nothing ever + /// writes `#0`), mirroring the contract grammar where `phase` is an + /// omittable key. The numbers are the contract-declared **seconds** + /// verbatim, which are already canonical. `#` can never appear in a + /// schema property name (`^[a-zA-Z0-9-_]{1,64}$`, dot-joined for nested + /// paths), so a qualified key can never collide with a plain property + /// level. + pub fn storage_key(&self, property_name: &str) -> String { + if self.phase_seconds == 0 { + format!( + "{}#{}#{}", + property_name, self.range_seconds, self.step_seconds + ) + } else { + format!( + "{}#{}#{}#{}", + property_name, self.range_seconds, self.step_seconds, self.phase_seconds + ) + } + } + /// The start of the most recent range that has begun at or before the - /// millisecond timestamp `t`, i.e. the largest `origin + k * step` that is + /// millisecond timestamp `t`, i.e. the largest `phase + k * step` that is /// `<= t`. /// - /// Returns `None` for `t` before the origin: no range has started yet, and - /// the first range's window `[origin, origin + range)` does not contain - /// such a `t`, so there is no honest answer. (Also `None` for a malformed - /// zero-step transform, which a validated contract can never carry.) + /// Returns `None` for `t` before the phase anchor — with a validated + /// transform (`phase < step`) that is only the sub-`step` sliver at the + /// epoch, which no real timestamp reaches; the arm is defensive. (Also + /// `None` for a malformed zero-step transform, which a validated contract + /// can never carry.) pub fn most_recent_start(&self, t: u64) -> Option { - let (step_ms, origin_ms) = (self.step_ms(), self.origin_ms()); - if step_ms == 0 || t < origin_ms { + let (step_ms, phase_ms) = (self.step_ms(), self.phase_ms()); + if step_ms == 0 || t < phase_ms { return None; } - let elapsed = t - origin_ms; - Some(origin_ms + (elapsed / step_ms) * step_ms) + let elapsed = t - phase_ms; + Some(phase_ms + (elapsed / step_ms) * step_ms) } /// All bucket-start values whose range `[start, start + range)` contains @@ -147,11 +191,11 @@ impl TimeRangeTransform { /// document with timestamp `t` must be written under. /// /// The result is sorted in descending order (newest range first) and has - /// exactly [`Self::overlap_factor`] elements, except near the origin where - /// fewer ranges have started. For `t` before the origin the result is - /// empty: the timestamp predates every range, so the document is not - /// indexed under any bucket (insert, delete and update all share this - /// rule, keeping the index consistent). + /// exactly [`Self::overlap_factor`] elements, except within the first + /// `range` after the epoch where fewer ranges have started. For `t` + /// before the phase anchor (the sub-`step` epoch sliver no real timestamp + /// reaches) the result is empty — insert, delete and update all share + /// this rule, keeping the index consistent. pub fn containing_buckets(&self, t: u64) -> Vec { let overlap = self.overlap_factor(); if overlap == 0 { @@ -160,13 +204,13 @@ impl TimeRangeTransform { let Some(newest) = self.most_recent_start(t) else { return Vec::new(); }; - let (step_ms, origin_ms) = (self.step_ms(), self.origin_ms()); + let (step_ms, phase_ms) = (self.step_ms(), self.phase_ms()); (0..overlap) .filter_map(|j| { let offset = j.checked_mul(step_ms)?; newest.checked_sub(offset) }) - .filter(|start| *start >= origin_ms) + .filter(|start| *start >= phase_ms) .collect() } @@ -175,8 +219,8 @@ impl TimeRangeTransform { /// returns documents from the latest partial slice — between `0` and one /// `step` of history. /// - /// Returns `None` when `now` predates the origin: no range has started - /// yet, so there is no active bucket to query. + /// Returns `None` only when `now` predates the phase anchor (the epoch + /// sliver; unreachable for real block times). pub fn newest_active_start(&self, now: u64) -> Option { self.most_recent_start(now) } @@ -187,8 +231,8 @@ impl TimeRangeTransform { /// history (between `range - step` and `range`). This is the bucket to /// query for "trending over the last range window". /// - /// Returns `None` when `now` predates the origin (no range has started - /// yet). + /// Returns `None` only when `now` predates the phase anchor (the epoch + /// sliver; unreachable for real block times). pub fn oldest_active_start(&self, now: u64) -> Option { let overlap = self.overlap_factor(); if overlap == 0 { @@ -196,7 +240,7 @@ impl TimeRangeTransform { } let newest = self.most_recent_start(now)?; let back = (overlap - 1).saturating_mul(self.step_ms()); - Some(newest.saturating_sub(back).max(self.origin_ms())) + Some(newest.saturating_sub(back).max(self.phase_ms())) } /// The set of index-entry keys a document with the given raw encoded @@ -212,9 +256,8 @@ impl TimeRangeTransform { /// ordinary null entry every index gives null values. /// - A decodable millisecond timestamp yields one key per containing /// bucket — the bucket *start*, encoded exactly like the timestamp - /// itself — and **no keys at all** when the timestamp predates the - /// origin (it belongs to no range; such a document is not present in - /// this index). + /// itself. (A timestamp inside the sub-`step` epoch sliver before the + /// phase anchor yields no keys; no real timestamp reaches it.) /// - A non-empty value that fails to decode keeps its raw key, exactly as /// a non-time-range index would store it. pub fn entry_keys_for_raw(&self, raw: &[u8]) -> Vec> { @@ -243,12 +286,12 @@ mod tests { const HOUR_MS: u64 = 3_600_000; fn transform() -> TimeRangeTransform { - // range = 6h, step = 2h, origin = 0 → overlap factor 3. + // range = 6h, step = 2h, phase = 0 → overlap factor 3. TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 6 * HOUR_SECONDS, step_seconds: 2 * HOUR_SECONDS, - origin_seconds: 0, + phase_seconds: 0, } } @@ -266,7 +309,7 @@ mod tests { source: "$createdAt".to_string(), range_seconds: u64::MAX, step_seconds: u64::MAX, - origin_seconds: 0, + phase_seconds: 0, }; assert_eq!(t.range_ms(), u64::MAX); assert_eq!(t.overlap_factor(), 1); @@ -281,31 +324,31 @@ mod tests { assert_eq!(t.most_recent_start(7 * h), Some(6 * h)); // exactly on a boundary stays put assert_eq!(t.most_recent_start(6 * h), Some(6 * h)); - // exactly at the origin is the first range + // exactly at the epoch is the first range assert_eq!(t.most_recent_start(0), Some(0)); } #[test] - fn pre_origin_timestamps_have_no_buckets() { - // A one-minute window stepping every twenty seconds, its grid anchored - // at the 1_000-second mark — 1_000_000 ms into the epoch. + fn the_epoch_sliver_before_the_phase_has_no_buckets() { + // A one-minute window stepping every twenty seconds, its grid phased + // five seconds into each step. The only timestamps outside every + // range are the first five seconds of 1970 — a sliver no real + // timestamp reaches; the math must still answer it honestly. let t = TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 60, step_seconds: 20, - origin_seconds: 1_000, + phase_seconds: 5, }; - // a timestamp before the origin belongs to no range: it must not be - // indexed under any bucket, and no range is active yet - assert_eq!(t.most_recent_start(999_999), None); - assert_eq!(t.containing_buckets(999_999), Vec::::new()); - assert_eq!(t.newest_active_start(999_999), None); - assert_eq!(t.oldest_active_start(999_999), None); - // at the origin the first range starts - assert_eq!(t.most_recent_start(1_000_000), Some(1_000_000)); - assert_eq!(t.containing_buckets(1_000_000), vec![1_000_000]); + assert_eq!(t.most_recent_start(4_999), None); + assert_eq!(t.containing_buckets(4_999), Vec::::new()); + assert_eq!(t.newest_active_start(4_999), None); + assert_eq!(t.oldest_active_start(4_999), None); + // at the phase anchor the first range starts + assert_eq!(t.most_recent_start(5_000), Some(5_000)); + assert_eq!(t.containing_buckets(5_000), vec![5_000]); // every returned bucket actually contains the timestamp - for now in [1_000_000u64, 1_010_000, 1_059_000, 1_100_000] { + for now in [5_000u64, 15_000, 64_999, 105_000] { for start in t.containing_buckets(now) { assert!(start <= now && now < start + t.range_ms()); } @@ -325,7 +368,7 @@ mod tests { } #[test] - fn containing_buckets_truncate_near_origin() { + fn containing_buckets_truncate_near_the_epoch() { let t = transform(); let h = HOUR_MS; // doc at 3h: ranges starting at 2h and 0h (4h start would be in future) @@ -365,28 +408,64 @@ mod tests { ); // an undecodable non-empty value keeps its raw key assert_eq!(t.entry_keys_for_raw(&[1, 2, 3]), vec![vec![1, 2, 3]]); - // a pre-origin timestamp belongs to no range: no keys - let t_offset = TimeRangeTransform { + // a timestamp inside the epoch sliver belongs to no range: no keys + let t_phased = TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 60, step_seconds: 20, - origin_seconds: 1_000, + phase_seconds: 5, }; - let raw = DocumentPropertyType::encode_date_timestamp(999_999); - assert_eq!(t_offset.entry_keys_for_raw(&raw), Vec::>::new()); + let raw = DocumentPropertyType::encode_date_timestamp(4_999); + assert_eq!(t_phased.entry_keys_for_raw(&raw), Vec::>::new()); } #[test] - fn origin_offset_shifts_alignment() { + fn phase_shifts_alignment() { let t = TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 60, step_seconds: 20, - origin_seconds: 5, + phase_seconds: 5, }; // starts are the 5th, 25th, 45th, ... second; now = the 50th second → // most recent start is the 45th assert_eq!(t.most_recent_start(50_000), Some(45_000)); assert_eq!(t.containing_buckets(50_000), vec![45_000, 25_000, 5_000]); } + + #[test] + fn storage_keys_qualify_the_property_with_the_grid() { + // phase 0 is spelled by omission — the canonical three-part form + assert_eq!( + transform().storage_key("$createdAt"), + "$createdAt#21600#7200" + ); + // a non-zero phase appends the fourth part + let t = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 60, + step_seconds: 20, + phase_seconds: 5, + }; + assert_eq!(t.storage_key("$createdAt"), "$createdAt#60#20#5"); + // two grids over the same property produce distinct sibling keys — + // the collision the qualification exists to prevent (every 6h start + // is also a 3h start, so unqualified keys would interleave) + let six_hourly = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 6 * HOUR_SECONDS, + phase_seconds: 0, + }; + let three_hourly = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 3 * HOUR_SECONDS, + step_seconds: 3 * HOUR_SECONDS, + phase_seconds: 0, + }; + assert_ne!( + six_hourly.storage_key("$createdAt"), + three_hourly.storage_key("$createdAt") + ); + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs b/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs index 4939d1aad13..8f1d0c4a7b5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs @@ -173,8 +173,8 @@ impl IndexLevel { if self.time_range() != new.time_range() { let fmt = |t: Option<&super::TimeRangeTransform>| match t { Some(t) => format!( - "Some(on: {:?}, range: {}s, step: {}s, origin: {}s)", - t.source, t.range_seconds, t.step_seconds, t.origin_seconds + "Some(on: {:?}, range: {}s, step: {}s, phase: {}s)", + t.source, t.range_seconds, t.step_seconds, t.phase_seconds ), None => "None".to_string(), }; diff --git a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs index 0baadf8966a..3c96ae29951 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs @@ -12,7 +12,6 @@ use crate::data_contract::document_type::index_level::IndexType::{ ContestedResourceIndex, NonUniqueIndex, UniqueIndex, }; use crate::data_contract::document_type::Index; -use crate::data_contract::errors::DataContractError; #[cfg(feature = "validation")] use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; @@ -257,27 +256,26 @@ impl IndexLevel { let mut counter: u64 = 0; - // First-property nodes that have already been visited, with the - // transform (or absence of one) their first visitor recorded. All - // indices sharing a first property must agree on its time-range - // transform — the walkers read the transform off the *merged* node, - // so a disagreement would bucket one index's entries and not - // another's. `try_from_schema` also rejects this under full - // validation; enforcing it here as well covers every construction - // path (check_tx, deserialized state, hand-built document types) - // instead of silently letting the last writer win. - let mut first_property_transforms: BTreeMap> = - BTreeMap::new(); - for index_to_borrow in indices { let index = index_to_borrow.borrow(); let mut current_level = &mut index_level; let mut properties_iter = index.properties.iter().enumerate().peekable(); while let Some((position, index_part)) = properties_iter.next() { + // A time-range transform always targets the index's first + // property, and its level is keyed by the property name + // *qualified with the grid* (`Index::level_key`, backed by + // `TimeRangeTransform::storage_key`) rather than the bare + // name. That fork is what lets several grids over one + // timestamp — and a plain index over the same timestamp — + // coexist: each grid's bucket starts live in their own + // subtree instead of interleaving in one keyspace. Identical + // grids map to the identical key, so indices sharing a grid + // still share the level. + let level_key = index.level_key(position, &index_part.name); current_level = current_level .sub_index_levels - .entry(index_part.name.clone()) + .entry(level_key) .or_insert_with(|| { counter += 1; IndexLevel { @@ -288,28 +286,7 @@ impl IndexLevel { } }); - // A time-range transform always targets the index's first - // property, so record it on that first-property node — - // rejecting any disagreement between indices that share it - // (see `first_property_transforms` above). if position == 0 { - match first_property_transforms.get(&index_part.name) { - Some(existing) if *existing != index.time_range => { - return Err(ProtocolError::DataContractError( - DataContractError::InvalidContractStructure(format!( - "indices that share the first property \"{}\" must agree on \ - its timeRange transform: either all bucket it identically \ - or none do", - index_part.name - )), - )); - } - Some(_) => {} - None => { - first_property_transforms - .insert(index_part.name.clone(), index.time_range.clone()); - } - } if let Some(transform) = &index.time_range { current_level.time_range = Some(transform.clone()); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs index 6ce8b55844d..ba062b7f737 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs @@ -64,7 +64,7 @@ impl Platform { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs index c0a6beff292..ffe9cbe7b20 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs @@ -248,7 +248,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation @@ -340,7 +340,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs index 5f66cc05563..17ee3e1b21c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs @@ -241,7 +241,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Diff vs `_v0` (parent-domain query): @@ -355,7 +355,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Diff vs `_v0` (preorder query): same change as above. `_v0` diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs index 10d0750bcb2..0f399d63ff9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs @@ -77,7 +77,7 @@ pub(super) fn delete_withdrawal_data_trigger_v0( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs index 59b4cb4e641..13d59b238f6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs @@ -71,7 +71,7 @@ pub(super) fn delete_withdrawal_data_trigger_v1( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Diff vs `_v0` (withdrawal-document lookup): diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index 93f2e129fdb..b8f3b475887 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -122,7 +122,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v0( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation @@ -183,7 +183,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v1( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via @@ -309,7 +309,7 @@ fn fetch_document_with_id_v0( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation @@ -372,7 +372,7 @@ fn fetch_document_with_id_v1( start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs index 94848417b8f..f6528d6afad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs @@ -456,7 +456,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let documents = platform @@ -504,7 +504,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let documents = platform @@ -913,7 +913,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let documents = platform @@ -948,7 +948,7 @@ mod dpns_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let documents = platform @@ -1182,7 +1182,7 @@ mod dpns_username_transfer_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; platform @@ -1278,7 +1278,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; match platform @@ -1322,7 +1322,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; match platform @@ -1366,7 +1366,7 @@ mod dpns_username_transfer_tests { limit: None, prove: false, drive_config: &platform.config.drive, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; match platform @@ -1434,7 +1434,7 @@ mod dpns_username_transfer_tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index b038b076eba..179da4d0162 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -2600,7 +2600,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), @@ -2983,7 +2983,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), diff --git a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs index 850adaa615a..bb245961d83 100644 --- a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs @@ -16,6 +16,7 @@ use dpp::platform_value::Value; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{DriveDocumentQuery, OrderClause, WhereClause}; use drive::util::grove_operations::GroveDBToUse; @@ -160,7 +161,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_by_clauses: Vec, limit_u32: Option, prove: bool, @@ -254,7 +255,7 @@ impl Platform { // hand-written one, so the provenance the v1 handler established is // attached here; index selection reads it to pin the query to the // index that buckets the field. - drive_query.resolved_time_range_fields = resolved_time_range_fields; + drive_query.resolved_time_ranges = resolved_time_ranges; let response = if prove { let proof = @@ -653,7 +654,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let request = GetDocumentsRequestV0 { @@ -727,7 +728,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let request = GetDocumentsRequestV0 { @@ -813,7 +814,7 @@ mod tests { start_at: Some(after), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let request = GetDocumentsRequestV0 { @@ -986,7 +987,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1154,7 +1155,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1310,7 +1311,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1477,7 +1478,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1661,7 +1662,7 @@ mod tests { start_at: Some(after.to_buffer()), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let mut where_clauses: Vec<_> = drive_document_query diff --git a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs index 59c3ce9632f..b608a7c9952 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs @@ -36,6 +36,7 @@ use dapi_grpc::platform::v0::get_documents_request::{ WhereOperator as ProtoWhereOperator, }; use dpp::platform_value::Value; +use drive::query::TimeRangeGridSpec; use drive::query::{ HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, OrderClause, SelectFunction, SelectProjection, TimeRangeSelector, WhereClause, WhereOperator, @@ -88,30 +89,103 @@ pub(super) fn is_time_range_clause(clause: &ProtoWhereClause) -> bool { clause.operator == ProtoWhereOperator::InTimeRange as i32 } -/// Decode an `IN_TIME_RANGE` wire where clause into its `(field, selector)`. -/// The operand carries the selector as `DocumentFieldValue.text` -/// (`"newest"` or `"oldest"`). +/// Decode an `IN_TIME_RANGE` wire where clause into its +/// `(field, selector, grid)`. Two operand shapes: +/// +/// - `DocumentFieldValue.text` — the bare selector (`"newest"` / +/// `"oldest"`). Unambiguous only while exactly one grid buckets the +/// field; the resolver rejects it otherwise. +/// - `DocumentFieldValue.list` — `[text(selector), uint64(range), +/// uint64(step)]` or `[…, uint64(phase)]`, naming one grid in the +/// contract's own declared units (seconds). Required when several grids +/// bucket the field. Like the contract grammar and the storage key, a +/// zero phase is spelled by omission — the three-element form — so every +/// grid has exactly one wire spelling. pub(super) fn time_range_clause_from_proto( clause: ProtoWhereClause, -) -> Result<(String, TimeRangeSelector), QueryError> { +) -> Result<(String, TimeRangeSelector, Option), QueryError> { let field = clause.field; - let selector_text = match clause.value.and_then(|v| v.variant) { - Some(document_field_value::Variant::Text(s)) => s, - _ => { - return Err(QueryError::InvalidArgument(format!( - "IN_TIME_RANGE clause on field '{}' must carry a text operand of \ - \"newest\" or \"oldest\"", - field - ))) - } + let parse_selector = |text: &str| { + TimeRangeSelector::from_string(text).ok_or_else(|| { + QueryError::InvalidArgument(format!( + "IN_TIME_RANGE selector must be \"newest\" or \"oldest\", got \"{}\"", + text + )) + }) }; - let selector = TimeRangeSelector::from_string(&selector_text).ok_or_else(|| { - QueryError::InvalidArgument(format!( - "IN_TIME_RANGE selector must be \"newest\" or \"oldest\", got \"{}\"", - selector_text - )) - })?; - Ok((field, selector)) + match clause.value.and_then(|v| v.variant) { + Some(document_field_value::Variant::Text(selector_text)) => { + Ok((field, parse_selector(&selector_text)?, None)) + } + Some(document_field_value::Variant::List(list)) => { + let mut values = list.values.into_iter(); + let selector = match values.next().and_then(|v| v.variant) { + Some(document_field_value::Variant::Text(s)) => parse_selector(&s)?, + _ => { + return Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE list operand on field '{}' must start with the \ + text selector \"newest\" or \"oldest\"", + field + ))) + } + }; + let mut grid_number = |name: &str| -> Result { + match values.next().and_then(|v| v.variant) { + Some(document_field_value::Variant::Uint64Value(n)) => Ok(n), + _ => Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE list operand on field '{}' must carry the grid's \ + {} as a uint64 of seconds, exactly as the contract declares it", + field, name + ))), + } + }; + let range_seconds = grid_number("range")?; + let step_seconds = grid_number("step")?; + let phase_seconds = match values.next() { + None => 0, + Some(v) => match v.variant { + Some(document_field_value::Variant::Uint64Value(0)) => { + return Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE list operand on field '{}': a zero phase is \ + spelled by omission (use the three-element form), so every \ + grid has exactly one wire spelling", + field + ))) + } + Some(document_field_value::Variant::Uint64Value(n)) => n, + _ => { + return Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE list operand on field '{}' must carry the \ + grid's phase as a uint64 of seconds", + field + ))) + } + }, + }; + if values.next().is_some() { + return Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE list operand on field '{}' takes at most \ + [selector, range, step, phase]", + field + ))); + } + Ok(( + field, + selector, + Some(TimeRangeGridSpec { + range_seconds, + step_seconds, + phase_seconds, + }), + )) + } + _ => Err(QueryError::InvalidArgument(format!( + "IN_TIME_RANGE clause on field '{}' must carry either a text operand \ + (\"newest\" / \"oldest\") or a list operand [selector, range, step] / \ + [selector, range, step, phase] naming one of the field's grids", + field + ))), + } } /// Map a wire [`ProtoDocumentFieldValue`] onto a diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs index e92178868b6..5e253f3fc31 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs @@ -21,6 +21,7 @@ use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{ AverageEntry as DriveAverageEntry, AverageMode, CountMode, DocumentAverageRequest, DocumentAverageResponse, OrderClause, WhereClause, @@ -46,7 +47,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -103,7 +104,7 @@ impl Platform { document_type, sum_property, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_clauses, mode: avg_mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs index 4d07cd68bb3..b5871c6013e 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs @@ -21,6 +21,7 @@ use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{ CountMode, DocumentCountRequest, DocumentCountResponse, OrderClause, SplitCountEntry, WhereClause, @@ -42,7 +43,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -89,7 +90,7 @@ impl Platform { contract: contract_ref, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_clauses, mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs index 1a3397bfb07..c2cefc2ba3b 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/documents.rs @@ -13,6 +13,7 @@ use dapi_grpc::platform::v0::get_documents_response::{ get_documents_response_v0, get_documents_response_v1, GetDocumentsResponseV1, }; use dpp::version::PlatformVersion; +use drive::query::ResolvedTimeRange; use drive::query::{OrderClause, WhereClause}; impl Platform { @@ -28,7 +29,7 @@ impl Platform { data_contract_id: Vec, document_type: String, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_by_clauses: Vec, limit: Option, start: Option, @@ -49,7 +50,7 @@ impl Platform { data_contract_id, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, prove, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs index ed459465130..10cd5b200ed 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs @@ -20,6 +20,7 @@ use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{ DocumentRankedRequest, DocumentRankedResponse, HavingClause, OrderClause, SelectProjection, WhereClause, @@ -60,7 +61,7 @@ impl Platform { group_by: Vec, having: Vec, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_clauses: Vec, limit: Option, offset: Option, @@ -104,7 +105,7 @@ impl Platform { having: &having, order_by: &order_clauses, where_clauses: &where_clauses, - resolved_time_range_fields: &resolved_time_range_fields, + resolved_time_ranges: &resolved_time_ranges, limit, offset, has_start_at: start.is_some(), diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs index 88dcafdef68..0e8ffa4f07a 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs @@ -20,6 +20,7 @@ use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{ CountMode, DocumentSumRequest, DocumentSumResponse, OrderClause, SumEntry as DriveSumEntry, SumMode, WhereClause, @@ -44,7 +45,7 @@ impl Platform { data_contract_id: Vec, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, order_clauses: Vec, limit: Option, start: Option, @@ -103,7 +104,7 @@ impl Platform { document_type, sum_property, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_clauses, mode: sum_mode, limit, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs index f09701edd18..e3088cdb45d 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs @@ -31,6 +31,7 @@ mod conversions; mod dispatch; mod routing; +use drive::query::ResolvedTimeRange; use routing::{reject_offset_off_the_ranked_path, validate_and_route}; // Re-exported so `tests.rs` (a `use super::*` consumer) keeps seeing // the routing probe under its old name. @@ -203,7 +204,7 @@ impl Platform { Ok(c) => c, Err(e) => return Ok(QueryValidationResult::new_with_error(e)), }; - let mut resolved_time_range_fields: Vec = Vec::new(); + let mut resolved_time_ranges: Vec = Vec::new(); if !time_range_proto.is_empty() { // LOAD-BEARING TIME SOURCE: the verifier re-derives the bucket @@ -250,23 +251,26 @@ impl Platform { document_type, contract_id )))); for proto_wc in time_range_proto { - let (field, selector) = match conversions::time_range_clause_from_proto(proto_wc) { - Ok(parsed) => parsed, - Err(e) => return Ok(QueryValidationResult::new_with_error(e)), - }; + let (field, selector, grid) = + match conversions::time_range_clause_from_proto(proto_wc) { + Ok(parsed) => parsed, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; match drive::query::resolve_time_range_bucket_clause( &field, selector, + grid, doc_type, block_time_ms, ) { - Ok(resolved) => { - where_clauses.push(resolved); + Ok((clause, resolved)) => { + where_clauses.push(clause); // The resolved clause is an ordinary equality; only // this list tells the executors that it must be - // matched against bucket starts rather than raw - // timestamps, so it travels with the request. - resolved_time_range_fields.push(field); + // matched against bucket starts of the resolved + // grid rather than raw timestamps (or another + // grid's starts), so it travels with the request. + resolved_time_ranges.push(resolved); } Err(drive::error::Error::Query(qe)) => { return Ok(QueryValidationResult::new_with_error(QueryError::Query(qe))) @@ -331,7 +335,7 @@ impl Platform { data_contract_id, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, start, @@ -343,7 +347,7 @@ impl Platform { data_contract_id, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, start, @@ -356,7 +360,7 @@ impl Platform { data_contract_id, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, start, @@ -370,7 +374,7 @@ impl Platform { data_contract_id, document_type, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, start, @@ -387,7 +391,7 @@ impl Platform { group_by, having_clauses, where_clauses, - resolved_time_range_fields, + resolved_time_ranges, order_by_clauses, limit, offset, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index f74d90812be..e47ac126c5c 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -4584,27 +4584,28 @@ mod time_range_proof_verification { operator: WhereOperator::Equal, value: Value::Text(hashtag.to_string()), }]; - let resolved = drive::query::resolve_time_range_bucket_clause( + let (clause, resolution) = drive::query::resolve_time_range_bucket_clause( CREATED_AT, TimeRangeSelector::Newest, + None, *document_type, time_ms, ) .expect("the metadata time falls inside an active range"); - let bucket_start = resolved + let bucket_start = clause .value .to_integer::() .expect("a resolved bucket start is a millisecond timestamp"); - where_clauses.push(resolved); - let resolved_fields = vec![CREATED_AT.to_string()]; + where_clauses.push(clause); + let resolutions = vec![resolution]; - drive::query::validate_resolved_time_range_clause_shapes(&where_clauses, &resolved_fields) + drive::query::validate_resolved_time_range_clause_shapes(&where_clauses, &resolutions) .expect("resolution produces exactly the one equality the guard admits"); let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - &resolved_fields, + &resolutions, ) .expect("the bucketed index covers the resolved clause set"); assert_eq!( @@ -4763,15 +4764,15 @@ mod time_range_proof_verification { operator: WhereOperator::Equal, value: Value::Text("ibiza".to_string()), }]; - where_clauses.push( - drive::query::resolve_time_range_bucket_clause( - CREATED_AT, - TimeRangeSelector::Newest, - document_type, - mtd.time_ms, - ) - .expect("the metadata time falls inside an active range"), - ); + let (resolved_clause, resolution) = drive::query::resolve_time_range_bucket_clause( + CREATED_AT, + TimeRangeSelector::Newest, + None, + document_type, + mtd.time_ms, + ) + .expect("the metadata time falls inside an active range"); + where_clauses.push(resolved_clause); let mut drive_query = DriveDocumentQuery::from_typed_clauses( where_clauses, Vec::new(), @@ -4785,7 +4786,7 @@ mod time_range_proof_verification { version, ) .expect("the resolved clause set builds a drive query"); - drive_query.resolved_time_range_fields = vec![CREATED_AT.to_string()]; + drive_query.resolved_time_ranges = vec![resolution]; let response = GetDocumentsResponse { version: Some(ResponseVersion::V1(GetDocumentsResponseV1 { @@ -5162,4 +5163,214 @@ mod time_range_proof_verification { .expect_err("an altered signed time must not yield a verified average"); assert_proof_or_signature_rejection(error); } + // ----- multiple grids over one timestamp ------------------------------ + + use drive::query::TimeRangeGridSpec; + + const DAILY_INDEX: &str = "daily"; + const DAY_SECONDS: u64 = 24 * 3_600; + + /// The trending contract plus a second, daily (24h/24h) grid over the + /// same `$createdAt`. Grid-qualified level keys give each grid its own + /// subtree; the tests below pin that the wire can address each grid and + /// that the two prove independently. + fn register_two_grid_contract( + platform: &Platform, + platform_version: &PlatformVersion, + ) -> DataContract { + let factory = DataContractFactory::new(platform_version.protocol_version) + .expect("expected a factory"); + let schemas = platform_value!({ + DOCUMENT_TYPE: { + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "indices": [ + { + "name": BUCKETED_INDEX, + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "countable": true, + "timeRange": { "on": "$createdAt", "range": 21_600u64, "step": 7_200u64 }, + }, + { + "name": DAILY_INDEX, + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "countable": true, + "timeRange": { "on": "$createdAt", "range": DAY_SECONDS, "step": DAY_SECONDS }, + }, + ], + "required": ["$createdAt", "hashtag"], + "additionalProperties": false, + } + }); + let contract = factory + .create_with_value_config(Identifier::new([9u8; 32]), 0, schemas, None, None) + .expect("a contract may bucket one timestamp with several grids") + .data_contract_owned(); + store_data_contract(platform, &contract, platform_version); + contract + } + + fn setup_two_grids( + platform: &TempPlatform, + base_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> (DataContract, PlatformState) { + let contract = register_two_grid_contract(platform, platform_version); + insert_posts(platform, &contract, platform_version); + let state = state_with_committed_block_time(base_state, &platform.drive, platform_version); + (contract, state) + } + + /// The wire's structured `IN_TIME_RANGE` operand: + /// `[selector, range, step]` (seconds, as the contract declares them). + fn structured_where_clauses(hashtag: &str, grid: TimeRangeGridSpec) -> Vec { + let uint = |n: u64| ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Uint64Value(n)), + }; + let mut values = vec![ + ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Text( + TimeRangeSelector::Newest.as_str().to_string(), + )), + }, + uint(grid.range_seconds), + uint(grid.step_seconds), + ]; + if grid.phase_seconds != 0 { + values.push(uint(grid.phase_seconds)); + } + vec![ + wc( + "hashtag", + ProtoWhereOperator::Equal, + Value::Text(hashtag.to_string()), + ), + ProtoWhereClause { + field: CREATED_AT.to_string(), + operator: ProtoWhereOperator::InTimeRange as i32, + value: Some(ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::List( + document_field_value::ValueList { values }, + )), + }), + }, + ] + } + + const TRENDING_GRID: TimeRangeGridSpec = TimeRangeGridSpec { + range_seconds: 21_600, + step_seconds: 7_200, + phase_seconds: 0, + }; + const DAILY_GRID: TimeRangeGridSpec = TimeRangeGridSpec { + range_seconds: DAY_SECONDS, + step_seconds: DAY_SECONDS, + phase_seconds: 0, + }; + + /// With two grids on `$createdAt`, the bare text selector no longer + /// names a grid, so the handler refuses it as ambiguous rather than + /// picking one — a silent pick would prove an answer to a question the + /// client didn't ask. + #[test] + fn a_bare_selector_on_a_multi_grid_field_is_refused_as_ambiguous() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_two_grids(&platform, &base_state, version); + + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let result = platform + .query_documents_v1(request, &state, version) + .expect("transport-level success"); + assert!( + !result.errors.is_empty(), + "a bare selector over two grids must be refused" + ); + assert!( + format!("{:?}", result.errors).contains("grids"), + "the refusal must say the field is multi-grid: {:?}", + result.errors + ); + } + + /// A four-element operand spelling a zero phase is refused: like the + /// contract grammar and the storage key, zero is spelled by omission so + /// every grid has exactly one wire spelling. + #[test] + fn an_explicit_zero_phase_operand_is_refused_as_non_canonical() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_two_grids(&platform, &base_state, version); + + let mut clauses = structured_where_clauses("ibiza", TRENDING_GRID); + // append an explicit zero phase to the list operand + if let Some(ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::List(list)), + }) = clauses[1].value.as_mut() + { + list.values.push(ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Uint64Value(0)), + }); + } else { + panic!("the helper builds a list operand"); + } + let request = GetDocumentsRequestV1 { + where_clauses: clauses, + ..trending_request(contract.id().to_vec(), "ibiza", select_count_star()) + }; + let result = platform + .query_documents_v1(request, &state, version) + .expect("transport-level success"); + assert!( + format!("{:?}", result.errors).contains("omission"), + "expected the one-spelling-per-grid refusal, got {:?}", + result.errors + ); + } + + /// Each grid proves and verifies independently through the SDK + /// `FromProof` entry point, with the structured operand naming the grid + /// on the wire and `with_time_range_grid` naming it in the client query. + /// The counts differ by design: the newest trending window holds two + /// `#ibiza` posts, while the newest daily window also contains the + /// one-hour-older post — three. A collapsed keyspace could not produce + /// both answers. + #[test] + fn each_grid_proves_and_verifies_independently_through_the_entry_point() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, state) = setup_two_grids(&platform, &base_state, version); + + for (grid, expected_count) in [(TRENDING_GRID, 2u64), (DAILY_GRID, 3u64)] { + let request = GetDocumentsRequestV1 { + where_clauses: structured_where_clauses("ibiza", grid), + ..trending_request(contract.id().to_vec(), "ibiza", select_count_star()) + }; + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let query = SdkDocumentQuery::new(Arc::new(contract.clone()), DOCUMENT_TYPE) + .expect("the fixture has this document type") + .with_select(SelectProjection::count_star()) + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("ibiza".to_string()), + }) + .with_time_range_grid(CREATED_AT, TimeRangeSelector::Newest, grid); + let (count, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + signed_response(proof, &mtd), + Network::Testnet, + version, + &provider, + ) + .expect("a correctly signed per-grid count must verify"); + assert_eq!( + count.expect("the bucket is not empty"), + DocumentCount(expected_count), + "grid {:?} must count its own bucket's members", + grid + ); + } + } } diff --git a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs index f0c74565eea..01e9ae063c0 100644 --- a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs +++ b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs @@ -150,7 +150,7 @@ fn document_query<'a>(case: &Case, contract: &'a DataContract) -> DriveDocumentQ start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } diff --git a/packages/rs-drive/benches/document_count_worst_case.rs b/packages/rs-drive/benches/document_count_worst_case.rs index bc96a785c7f..9052996727f 100644 --- a/packages/rs-drive/benches/document_count_worst_case.rs +++ b/packages/rs-drive/benches/document_count_worst_case.rs @@ -2285,7 +2285,7 @@ fn count_request<'a>( limit, prove, drive_config: &fixture.drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } diff --git a/packages/rs-drive/benches/document_sum_worst_case.rs b/packages/rs-drive/benches/document_sum_worst_case.rs index baf76f6ff1c..cd4fdc7bff9 100644 --- a/packages/rs-drive/benches/document_sum_worst_case.rs +++ b/packages/rs-drive/benches/document_sum_worst_case.rs @@ -1917,7 +1917,7 @@ fn sum_request<'a>( limit, prove, drive_config: &fixture.drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } diff --git a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs index 907b0efb0df..5774dd6fa5d 100644 --- a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs @@ -12,7 +12,6 @@ use crate::error::Error; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::DataContract; use dpp::serialization::PlatformSerializableWithPlatformVersion; @@ -25,7 +24,7 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; impl Drive { /// v1 of contract-insertion cost estimation. Differs from v0 by computing @@ -134,17 +133,14 @@ impl Drive { let mut tree_weights = TreeTypeWeights::default(); tree_weights.tally(pk_tree_type); + // One root sub-level per distinct top tree (grid-qualified keys + // for time-range first properties), matching the trees + // `insert_contract_v0` actually creates. The map is already + // deduped. let index_structure = document_type_ref.index_structure(); - let mut seen_indexes: HashSet<&[u8]> = HashSet::new(); - for index in document_type_ref.top_level_indices() { - let index_bytes = index.name.as_bytes(); - if !seen_indexes.insert(index_bytes) { - continue; - } - let terminator_tree_type = index_structure - .sub_levels() - .get(index.name.as_str()) - .and_then(|level| level.has_index_with_type()) + for level in index_structure.sub_levels().values() { + let terminator_tree_type = level + .has_index_with_type() .map(property_name_tree_type_from_flags) .unwrap_or(TreeType::NormalTree); tree_weights.tally(terminator_tree_type); diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs index 42aeab8eaec..20216b04eb6 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs @@ -15,7 +15,6 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; -use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::serialization::PlatformSerializableWithPlatformVersion; use crate::drive::votes::paths::{ @@ -25,7 +24,7 @@ use crate::error::contract::DataContractError; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; impl Drive { /// Insert a contract. @@ -361,14 +360,18 @@ impl Drive { )?, } - let mut index_cache: HashSet<&[u8]> = HashSet::new(); let document_type_ref = document_type.as_ref(); let index_structure = document_type_ref.index_structure(); - // for each type we should insert the indices that are top level - for index in document_type.as_ref().top_level_indices() { - // toDo: change this to be a reference by index - let index_bytes = index.name.as_bytes(); - if !index_cache.contains(index_bytes) { + // For each type we should insert the indices that are top level. + // The index structure's root sub-levels are exactly the distinct + // top-level trees: one per plain first property, plus one per + // (property, grid) pair for time-range-transformed first + // properties, whose keys are already grid-qualified + // (`TimeRangeTransform::storage_key`). Iterating the map also + // dedupes indexes sharing a first level for free. + for (level_key, level) in index_structure.sub_levels() { + let index_bytes = level_key.as_bytes(); + { // The property-name tree variant (the tree at // `@/contract/0x01//`) is selected from // the index's `(range_countable, range_summable)` @@ -407,10 +410,7 @@ impl Drive { // top-level property-name tree IS the terminal one. A // compound index's terminal level lives deeper and is // materialized lazily by the document index walker. - let index_info = index_structure - .sub_levels() - .get(index.name.as_str()) - .and_then(|level| level.has_index_with_type()); + let index_info = level.has_index_with_type(); let (tree_type, ranked_axes) = property_name_tree_type_and_ranked_axes(index_info)?; match tree_type { @@ -472,7 +472,6 @@ impl Drive { &platform_version.drive, )?, } - index_cache.insert(index_bytes); } } } diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs index ae2b4eea2f3..a158e287623 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs @@ -317,7 +317,7 @@ fn run( offset: Some(0), has_start_at: false, prove, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }, None, platform_version(), diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs index cff9ee88c41..e97037cc5b2 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/range_countable_index_e2e_tests.rs @@ -2950,7 +2950,7 @@ fn distinct_count_proof_rejects_limit_above_max_query_limit() { limit: Some(too_large), prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let result = drive.execute_document_count_request(request, None, pv); diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs index f74a9b8e508..9262a07f598 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs @@ -1507,7 +1507,7 @@ fn ranked_avg_page( offset: Some(offset), has_start_at: false, prove: false, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }, None, platform_version(), @@ -1554,7 +1554,7 @@ fn verified_ranked_avg_page( offset: Some(offset), has_start_at: false, prove: true, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }, None, platform_version(), diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs index 7366e1e9a0d..234496ea64e 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs @@ -15,14 +15,13 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; -use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::serialization::PlatformSerializableWithPlatformVersion; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; impl Drive { /// Updates a data contract. @@ -301,10 +300,13 @@ impl Drive { type_key.as_bytes(), ]; - let mut index_cache: HashSet<&[u8]> = HashSet::new(); let document_type_ref = document_type.as_ref(); let index_structure = document_type_ref.index_structure(); - // for each type we should insert the indices that are top level. + // For each type we should insert the indices that are top + // level — one root sub-level per distinct top tree (plain + // first properties by name, time-range grids by their + // qualified `TimeRangeTransform::storage_key`), the same + // iteration `insert_contract_v0` performs. // // `batch_insert_empty_tree_if_not_exists` is a no-op when the // index already exists, so this loop covers BOTH the @@ -320,13 +322,9 @@ impl Drive { // contract update silently created a NormalTree, diverging // from the layout a fresh insert would have produced and // breaking subsequent range-sum / range-count reads. - for index in document_type.as_ref().top_level_indices() { - let index_bytes = index.name.as_bytes(); - if !index_cache.contains(index_bytes) { - let index_info = index_structure - .sub_levels() - .get(index.name.as_str()) - .and_then(|level| level.has_index_with_type()); + for (level_key, level) in index_structure.sub_levels() { + { + let index_info = level.has_index_with_type(); // Meta schema v3 (PV14) additionally upgrades the // chosen variant to its indexed mirror when the index // declares a ranking axis; `ranked_axes` is empty for @@ -356,7 +354,7 @@ impl Drive { // indexes (unchanged on disk) and brand-new ones // (materialized with the dispatch-chosen variant). self.batch_insert_empty_index_tree_if_not_exists( - PathFixedSizeKeyRef((type_path, index.name.as_bytes())), + PathFixedSizeKeyRef((type_path, level_key.as_bytes())), target_tree_type, &ranked_axes, storage_flags.as_ref().map(|flags| flags.as_ref()), @@ -366,7 +364,6 @@ impl Drive { &mut batch_operations, drive_version, )?; - index_cache.insert(index_bytes); } } } else { @@ -461,13 +458,15 @@ impl Drive { )?, } - let mut index_cache: HashSet<&[u8]> = HashSet::new(); let document_type_ref = document_type.as_ref(); let index_structure = document_type_ref.index_structure(); - // for each type we should insert the indices that are top level - for index in document_type.as_ref().top_level_indices() { - let index_bytes = index.name.as_bytes(); - if !index_cache.contains(index_bytes) { + // For each type we should insert the indices that are top + // level — the index structure's root sub-levels, whose keys + // are grid-qualified for time-range first properties (see + // `insert_contract_v0`). + for (level_key, level) in index_structure.sub_levels() { + let index_bytes = level_key.as_bytes(); + { // Top-level index tree variant is selected from the // index's `(range_countable, range_summable)` pair — // identical 4-way dispatch as @@ -477,10 +476,7 @@ impl Drive { // sum- or range-countable top-level index added via // contract update, diverging on-disk layout from // fresh-insert contracts. - let index_info = index_structure - .sub_levels() - .get(index.name.as_str()) - .and_then(|level| level.has_index_with_type()); + let index_info = level.has_index_with_type(); let (tree_type, ranked_axes) = property_name_tree_type_and_ranked_axes(index_info)?; match tree_type { @@ -542,7 +538,6 @@ impl Drive { drive_version, )?, } - index_cache.insert(index_bytes); } } } diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 200a3e86e6c..92ce162e103 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -112,13 +112,23 @@ impl Drive { let mut index_path: Vec> = contract_document_type_path.clone(); index_path.push(Vec::from(name.as_bytes())); + // The level key is the path segment; the document value is read + // from the *source property* — they differ on a time-range + // level, whose key is grid-qualified + // (`TimeRangeTransform::storage_key`) while the timestamp lives + // under the bare property name. Mirrors the insert walker. + let property_name = sub_level + .time_range() + .map(|transform| transform.source.as_str()) + .unwrap_or(name.as_str()); + // with the example of the dashpay contract's first index // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId let document_top_field = document_and_contract_info .owned_document_info .document_info .get_raw_for_document_type( - name, + property_name, document_type, document_and_contract_info.owned_document_info.owner_id, Some((sub_level, event_id)), @@ -131,7 +141,11 @@ impl Drive { let document_top_field_estimated_size = document_and_contract_info .owned_document_info .document_info - .get_estimated_size_for_document_type(name, document_type, platform_version)?; + .get_estimated_size_for_document_type( + property_name, + document_type, + platform_version, + )?; if document_top_field_estimated_size > u8::MAX as u16 { return Err(Error::Fee(FeeError::Overflow( diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs index 7948156be7f..b649ced298c 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs @@ -185,7 +185,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs index 24d182f6d51..8011759db8c 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs @@ -377,7 +377,7 @@ impl Drive { // the tuple never moves and `allow_original` keeps its // meaning — the tuple changed exactly when one of the // index's other properties changed. - let mut resolved_time_range_fields = Vec::new(); + let mut resolved_time_ranges = Vec::new(); if let Some(transform) = &index.time_range { let Some(clause) = where_queries.get_mut(transform.source.as_str()) else { @@ -406,16 +406,20 @@ impl Drive { }; let timestamp = timestamp?; // A validated unique time-range index has overlap - // factor 1 (range == step), so a timestamp at or - // after the transform's origin yields exactly one - // containing bucket. An empty result means the - // timestamp predates the origin: such documents - // produce no index entries at all, so they cannot - // collide with anything under this index and the - // whole check is skipped for it. + // factor 1 (range == step), so any real timestamp + // yields exactly one containing bucket. An empty + // result means the timestamp falls in the + // sub-`step` epoch sliver before the grid's phase + // anchor: such documents produce no index entries + // at all, so they cannot collide with anything + // under this index and the whole check is skipped + // for it. let bucket_start = *transform.containing_buckets(timestamp).first()?; clause.value = platform_value!(bucket_start); - resolved_time_range_fields.push(transform.source.clone()); + resolved_time_ranges.push(crate::query::ResolvedTimeRange { + field: transform.source.clone(), + transform: transform.clone(), + }); } let query = DriveDocumentQuery { @@ -434,7 +438,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields, + resolved_time_ranges, }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 74d6eb1616c..9af3360aee8 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -745,11 +745,15 @@ mod unique_time_range_index_tests { /// bucket start is a millisecond timestamp. const DAY_SECONDS: u64 = 24 * 3_600; const DAY_MS: u64 = 24 * 3_600_000; - /// Start of the window every "same window" timestamp below lands in, in - /// both units. It is a whole number of days, so it is a bucket start both - /// under the default origin (0) and under an origin of that same window. - const WINDOW_SECONDS: u64 = 100 * DAY_SECONDS; + /// Start of the window every "same window" timestamp below lands in — + /// a whole number of days, so it is a bucket start on the default + /// (phase 0) daily grid. const WINDOW_MS: u64 = 100 * DAY_MS; + /// A half-day phase for the phased-grid test: bucket starts move to + /// `k * day + 12h`, and the only timestamps outside every window are the + /// first twelve hours of 1970 — the epoch sliver the probe must skip. + const PHASE_SECONDS: u64 = 12 * 3_600; + const PHASE_MS: u64 = 12 * 3_600_000; /// A `report` document type with a UNIQUE /// `(timeRange($createdAt, range = step = 1 day), author)` index: one @@ -757,9 +761,9 @@ mod unique_time_range_index_tests { /// so neither can be null and the terminator always takes the unique /// layout. /// - /// `origin_seconds` shifts the window grid; `None` leaves it at the - /// default (0), where no `u64` timestamp can predate the origin. - fn build_unique_daily_report_contract(origin_seconds: Option) -> DataContract { + /// `phase_seconds` shifts the window grid within one step (validation + /// requires `phase < step`); `None` leaves it at the default (0). + fn build_unique_daily_report_contract(phase_seconds: Option) -> DataContract { let factory = DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); let mut time_range = vec![ @@ -770,11 +774,8 @@ mod unique_time_range_index_tests { (Value::Text("range".to_string()), Value::U64(DAY_SECONDS)), (Value::Text("step".to_string()), Value::U64(DAY_SECONDS)), ]; - if let Some(origin_seconds) = origin_seconds { - time_range.push(( - Value::Text("origin".to_string()), - Value::U64(origin_seconds), - )); + if let Some(phase_seconds) = phase_seconds { + time_range.push((Value::Text("phase".to_string()), Value::U64(phase_seconds))); } let index_map = vec![ ( @@ -809,15 +810,15 @@ mod unique_time_range_index_tests { } fn setup(platform_version: &'static PlatformVersion) -> (Drive, DataContract) { - setup_with_origin(None, platform_version) + setup_with_phase(None, platform_version) } - fn setup_with_origin( - origin_seconds: Option, + fn setup_with_phase( + phase_seconds: Option, platform_version: &'static PlatformVersion, ) -> (Drive, DataContract) { let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_unique_daily_report_contract(origin_seconds); + let contract = build_unique_daily_report_contract(phase_seconds); drive .apply_contract( &contract, @@ -1114,49 +1115,51 @@ mod unique_time_range_index_tests { ); } - /// A `$createdAt` predating the transform's origin produces no index - /// entries at all (the insert walker writes none), so such a document - /// cannot collide with anything: the probe must skip this index entirely - /// rather than invent a bucket for a timestamp that belongs to none. + /// A `$createdAt` inside the epoch sliver before the grid's phase anchor + /// produces no index entries at all (the insert walker writes none), so + /// such a document cannot collide with anything: the probe must skip this + /// index entirely rather than invent a bucket for a timestamp that + /// belongs to none. No real timestamp reaches the sliver — this pins the + /// defensive rule all three walkers and the probe share. #[test] - fn pre_origin_candidate_skips_the_time_range_index_check() { + fn epoch_sliver_candidate_skips_the_time_range_index_check() { let platform_version = PlatformVersion::latest(); - let (drive, contract) = setup_with_origin(Some(WINDOW_SECONDS), platform_version); + let (drive, contract) = setup_with_phase(Some(PHASE_SECONDS), platform_version); - // A stored report in the transform's first window. + // A stored report in the phased grid's first window `[12h, 36h)`. insert_report( &drive, &contract, - WINDOW_MS + 3_600_000, + PHASE_MS + 3_600_000, "alice", platform_version, ); - // Same author, timestamp one millisecond before the origin: it belongs - // to no window, so there is nothing for it to duplicate. + // Same author, timestamp one millisecond before the phase anchor: it + // belongs to no window, so there is nothing for it to duplicate. let result = check_uniqueness( &drive, &contract, Identifier::from([0xAA; 32]), - WINDOW_MS - 1, + PHASE_MS - 1, "alice", UniquenessOfDataRequestUpdateType::NewDocument, platform_version, ); assert!( result.is_valid(), - "a pre-origin document is not indexed, so it cannot collide: {:?}", + "an epoch-sliver document is not indexed, so it cannot collide: {:?}", result.errors ); // Contrast: inside the first window the same author does collide, so - // the skip above is the origin talking and not a probe that silently + // the skip above is the sliver talking and not a probe that silently // stopped working on this contract. let result = check_uniqueness( &drive, &contract, Identifier::from([0xAA; 32]), - WINDOW_MS + 9 * 3_600_000, + PHASE_MS + 9 * 3_600_000, "alice", UniquenessOfDataRequestUpdateType::NewDocument, platform_version, diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index db1b8ae170d..b64e1d6ea96 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -73,11 +73,11 @@ mod time_range_index_e2e_tests { //! Also covers the other half of that contract: a bucket-start equality //! only means "bucket" when it came from `IN_TIME_RANGE` resolution, so //! index selection is pinned by - //! [`DriveDocumentQuery::resolved_time_range_fields`] rather than left to + //! [`DriveDocumentQuery::resolved_time_ranges`] rather than left to //! whichever index happens to cover the fields. use crate::config::DriveConfig; use crate::drive::Drive; - use crate::query::DriveDocumentQuery; + use crate::query::{DriveDocumentQuery, ResolvedTimeRange}; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -177,7 +177,7 @@ mod time_range_index_e2e_tests { document_type, bucket, None, - vec!["$createdAt".to_string()], + created_at_resolution(document_type), ); query .execute_raw_results_no_proof(drive, None, None, platform_version) @@ -186,15 +186,32 @@ mod time_range_index_e2e_tests { .len() } + /// The provenance a real `IN_TIME_RANGE` resolution against this document + /// type's (single) `$createdAt` grid would have produced: the field plus + /// the exact transform, which is what pins index selection to the grid. + fn created_at_resolution( + document_type: dpp::data_contract::document_type::DocumentTypeRef, + ) -> Vec { + let transform = document_type + .indexes() + .values() + .find_map(|index| index.time_range.clone()) + .expect("the fixture declares a time-range index"); + vec![ResolvedTimeRange { + field: transform.source.clone(), + transform, + }] + } + /// A `$createdAt == created_at` query, optionally ANDed with - /// `hashtag == `, carrying `resolved_time_range_fields` verbatim + /// `hashtag == `, carrying `resolved_time_ranges` verbatim /// so tests can drive both the resolved and the raw (empty) provenance. fn build_created_at_query<'a>( contract: &'a DataContract, document_type: dpp::data_contract::document_type::DocumentTypeRef<'a>, created_at: u64, hashtag: Option<&str>, - resolved_time_range_fields: Vec, + resolved_time_ranges: Vec, ) -> DriveDocumentQuery<'a> { let mut clauses = vec![Value::Array(vec![ Value::Text("$createdAt".to_string()), @@ -220,7 +237,7 @@ mod time_range_index_e2e_tests { PlatformVersion::latest(), ) .expect("build query"); - query.resolved_time_range_fields = resolved_time_range_fields; + query.resolved_time_ranges = resolved_time_ranges; query } @@ -668,7 +685,7 @@ mod time_range_index_e2e_tests { document_type, bucket, Some("ibiza"), - vec!["$createdAt".to_string()], + created_at_resolution(document_type), ); assert_eq!( resolved @@ -724,16 +741,17 @@ mod time_range_index_e2e_tests { /// servable shape and is refused rather than routed to whichever index /// happens to cover the fields. #[test] - fn two_resolved_time_range_fields_are_rejected() { + fn two_resolved_time_ranges_are_rejected() { let contract = build_competing_index_trending_contract(); let document_type = contract.document_type_for_name("post").expect("post"); - let query = build_created_at_query( - &contract, - document_type, - 6 * HOUR_MS, - Some("ibiza"), - vec!["$createdAt".to_string(), "hashtag".to_string()], - ); + let query = build_created_at_query(&contract, document_type, 6 * HOUR_MS, Some("ibiza"), { + let mut resolutions = created_at_resolution(document_type); + let mut second = resolutions[0].clone(); + second.field = "hashtag".to_string(); + second.transform.source = "hashtag".to_string(); + resolutions.push(second); + resolutions + }); let error = query .find_best_index(PlatformVersion::latest()) .expect_err("two resolved time-range fields cannot be served"); @@ -797,7 +815,7 @@ mod time_range_index_e2e_tests { "byHashtagAndAuthor" ); - query.resolved_time_range_fields = vec!["$createdAt".to_string()]; + query.resolved_time_ranges = created_at_resolution(document_type); let error = query .find_best_index(PlatformVersion::latest()) .expect_err("no bucketed index covers the ordering"); @@ -904,7 +922,7 @@ mod time_range_index_e2e_tests { PlatformVersion::latest(), ) .expect("build query"); - query.resolved_time_range_fields = vec!["$createdAt".to_string()]; + query.resolved_time_ranges = created_at_resolution(document_type); query .execute_raw_results_no_proof(drive, None, None, platform_version) .expect("query") @@ -1109,4 +1127,315 @@ mod time_range_index_e2e_tests { "expected a no-covering-index rejection, got {error:?}" ); } + /// The multi-grid contract: one timestamp, two grids, sibling subtrees. + /// A 6h/2h "trending" grid and a 24h/24h "daily" grid both bucket + /// `$createdAt`; each level is keyed by the grid-qualified storage key, + /// so the two coexist — including bucket starts that are numerically + /// identical across grids (every daily start is also a trending start). + fn build_two_grid_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let grid_index = |name: &str, range_seconds: u64, step_seconds: u64| { + Value::Map(vec![ + ( + Value::Text("name".to_string()), + Value::Text(name.to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(range_seconds)), + (Value::Text("step".to_string()), Value::U64(step_seconds)), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]) + }; + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![ + grid_index("trending", 6 * HOUR_SECONDS, 2 * HOUR_SECONDS), + grid_index("daily", 24 * HOUR_SECONDS, 24 * HOUR_SECONDS), + ]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + factory + .create_with_value_config(generate_random_identifier_struct(), 0, schemas, None, None) + .expect("a contract may bucket one timestamp with several grids") + .data_contract_owned() + } + + /// The provenance of a resolution against one named grid of a + /// multi-grid document type. + fn grid_resolution(contract: &DataContract, index_name: &str) -> Vec { + let transform = contract + .document_type_for_name("post") + .expect("post") + .indexes() + .get(index_name) + .expect("the fixture declares this index") + .time_range + .clone() + .expect("the index carries a transform"); + vec![ResolvedTimeRange { + field: transform.source.clone(), + transform, + }] + } + + /// Two grids over `$createdAt`: a document fans out into each grid's own + /// subtree, a resolution against one grid reads only that grid's bucket + /// — even when the two grids' bucket starts are the same number — and + /// deletion empties both. The bucket start chosen here (24h) is + /// deliberately a start on BOTH grids: without grid-qualified level keys + /// the two entry sets would interleave in one keyspace and the counts + /// below would be wrong in both directions. + #[test] + fn two_grids_over_one_timestamp_write_and_read_independently() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_two_grid_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + + // 25h10m: the daily grid buckets it at 24h; the trending grid at + // [24h, 22h, 20h]. 24h is a bucket start on BOTH grids. + let created_at = 25 * HOUR_MS + 10 * 60_000; + let shared_bucket = 24 * HOUR_MS; + + let trending = grid_resolution(&contract, "trending"); + let daily = grid_resolution(&contract, "daily"); + assert_eq!( + trending[0] + .transform + .containing_buckets(created_at) + .first() + .copied(), + Some(shared_bucket) + ); + assert_eq!( + daily[0].transform.containing_buckets(created_at), + vec![shared_bucket], + "the same numeric start on both grids is the point of this fixture" + ); + + let owner_bytes = rand::random::<[u8; 32]>(); + let document = Document::V0(DocumentV0 { + id: Identifier::from(rand::random::<[u8; 32]>()), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + let count_for = |resolutions: &Vec, bucket: u64| -> usize { + let query = build_created_at_query( + &contract, + document_type, + bucket, + Some("ibiza"), + resolutions.clone(), + ); + query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query") + .0 + .len() + }; + + // Selection pins to the resolved grid's index. + let trending_query = build_created_at_query( + &contract, + document_type, + shared_bucket, + Some("ibiza"), + trending.clone(), + ); + assert_eq!( + trending_query + .find_best_index(platform_version) + .expect("the trending grid's index serves its own resolution") + .name, + "trending" + ); + let daily_query = build_created_at_query( + &contract, + document_type, + shared_bucket, + Some("ibiza"), + daily.clone(), + ); + assert_eq!( + daily_query + .find_best_index(platform_version) + .expect("the daily grid's index serves its own resolution") + .name, + "daily" + ); + + // Each grid's subtree holds the document under the shared start, and + // the trending grid additionally holds it under its two older + // overlapping starts — which the daily grid must NOT see. + assert_eq!(count_for(&trending, shared_bucket), 1); + assert_eq!(count_for(&daily, shared_bucket), 1); + assert_eq!(count_for(&trending, 22 * HOUR_MS), 1); + assert_eq!( + count_for(&daily, 22 * HOUR_MS), + 0, + "22h is a trending fan-out entry only; leaking it into the daily \ + grid would mean the levels share a keyspace again" + ); + + // Deletion empties both grids' subtrees. + drive + .delete_document_for_contract( + document.id(), + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + assert_eq!(count_for(&trending, shared_bucket), 0); + assert_eq!(count_for(&trending, 22 * HOUR_MS), 0); + assert_eq!(count_for(&daily, shared_bucket), 0); + } + + /// Resolution over a multi-grid field: the bare selector is ambiguous + /// and refused; a grid spec picks exactly the named grid; a spec no + /// index declares is refused. This is the query-language half of the + /// storage fork the previous test pins. + #[test] + fn multi_grid_resolution_requires_and_honors_a_grid_spec() { + use crate::query::TimeRangeGridSpec; + + let contract = build_two_grid_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let now_ms = 25 * HOUR_MS; + + let error = crate::query::resolve_time_range_bucket_clause( + "$createdAt", + crate::query::TimeRangeSelector::Newest, + None, + document_type, + now_ms, + ) + .expect_err("two grids on the field make the bare selector ambiguous"); + assert!( + matches!( + error, + crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) + ), + "expected the ambiguity rejection, got {error:?}" + ); + + let (clause, resolution) = crate::query::resolve_time_range_bucket_clause( + "$createdAt", + crate::query::TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 24 * HOUR_SECONDS, + step_seconds: 24 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect("naming the daily grid resolves against it"); + assert_eq!(clause.value, Value::U64(24 * HOUR_MS)); + assert_eq!(resolution.transform.range_seconds, 24 * HOUR_SECONDS); + + let (clause, resolution) = crate::query::resolve_time_range_bucket_clause( + "$createdAt", + crate::query::TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 2 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect("naming the trending grid resolves against it"); + assert_eq!( + clause.value, + Value::U64(24 * HOUR_MS), + "at 25h both grids' newest start is 24h — same number, different \ + subtree, which is exactly why provenance carries the grid" + ); + assert_eq!(resolution.transform.step_seconds, 2 * HOUR_SECONDS); + + let error = crate::query::resolve_time_range_bucket_clause( + "$createdAt", + crate::query::TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 12 * HOUR_SECONDS, + step_seconds: 12 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect_err("a grid no index declares must be refused"); + assert!( + matches!( + error, + crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) + ), + "expected the unknown-grid rejection, got {error:?}" + ); + } } diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 125daccd081..5389953abcd 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -119,13 +119,23 @@ impl Drive { let mut index_path: Vec> = contract_document_type_path.clone(); index_path.push(Vec::from(name.as_bytes())); + // The level key is the path segment; the document value is read + // from the *source property*. They coincide except on a + // time-range level, whose key is the property name qualified + // with the grid (`TimeRangeTransform::storage_key`) while the + // timestamp still lives under the bare property name. + let property_name = sub_level + .time_range() + .map(|transform| transform.source.as_str()) + .unwrap_or(name.as_str()); + // with the example of the dashpay contract's first index // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId let document_top_field = document_and_contract_info .owned_document_info .document_info .get_raw_for_document_type( - name, + property_name, document_type, document_and_contract_info.owned_document_info.owner_id, Some((sub_level, event_id)), @@ -155,7 +165,11 @@ impl Drive { let document_top_field_estimated_size = document_and_contract_info .owned_document_info .document_info - .get_estimated_size_for_document_type(name, document_type, platform_version)?; + .get_estimated_size_for_document_type( + property_name, + document_type, + platform_version, + )?; if document_top_field_estimated_size > u8::MAX as u16 { return Err(Error::Fee(FeeError::Overflow( diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index ae1c0a43b12..56f0e005787 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -311,7 +311,16 @@ impl Drive { let top_index_property = index.properties.first().ok_or(Error::Drive( DriveError::CorruptedContractIndexes("invalid contract indices".to_string()), ))?; - index_path.push(Vec::from(top_index_property.name.as_bytes())); + // A time-range index's top level is keyed by the property name + // qualified with the grid (`TimeRangeTransform::storage_key`), + // so different grids over one timestamp live in sibling + // subtrees; a plain index keeps the bare name. The same key is + // both the path segment and the `IndexLevel` lookup below. + let top_level_key = match index.time_range.as_ref() { + Some(transform) => transform.storage_key(&top_index_property.name), + None => top_index_property.name.clone(), + }; + index_path.push(Vec::from(top_level_key.as_bytes())); // Mirror the insert path's IndexLevel descent. We // start at the top-level property's `IndexLevel` node — @@ -328,15 +337,16 @@ impl Drive { // there). Using `has_index_with_type()` on the descended // node ensures we pick that upgrade rather than the // currently-iterated index's own per-level flags. - let mut current_index_level = index_structure - .sub_levels() - .get(&top_index_property.name) - .ok_or(Error::Drive(DriveError::CorruptedContractIndexes(format!( - "index structure missing top property '{}' for index '{}' — \ + let mut current_index_level = + index_structure + .sub_levels() + .get(&top_level_key) + .ok_or(Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index structure missing top level '{}' for index '{}' — \ doctype's IndexLevel tree must contain every property of every \ registered index", - top_index_property.name, index.name - ))))?; + top_level_key, index.name + ))))?; // Per-index reference variant. Mirror of the insert path's // dispatch in @@ -364,14 +374,14 @@ impl Drive { // Time-range indexes store one entry per overlapping range bucket, // so they need a set-diff update rather than the single old→new // value transition below. `current_index_level` is still the - // top-level (source) node and `index_path` is the base - // (…//) at this point. The transform is - // read off the merged `IndexLevel` node — the same source the - // insert and delete walkers branch on — so all three walkers + // top-level node and `index_path` is the base + // (…//) at this point. The + // transform is read off the `IndexLevel` node — the same source + // the insert and delete walkers branch on — so all three walkers // agree even on a document type constructed outside full - // validation. (`IndexLevel::try_from_indices` rejects indices - // that share a first property but disagree on the transform, so - // the node's transform is every sharing index's transform.) + // validation. (A level's key embeds its grid, so the node's + // transform is exactly the grid every index sharing this level + // declared.) if let Some(transform) = current_index_level.time_range() { self.update_time_range_index_for_contract_operations_v1( index, diff --git a/packages/rs-drive/src/drive/document/update/mod.rs b/packages/rs-drive/src/drive/document/update/mod.rs index bbe866443e2..5f4addfe4d6 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -3264,7 +3264,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_response = drive .execute_document_sum_request(sum_request, None, platform_version) @@ -3502,7 +3502,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_response = drive .execute_document_sum_request(sum_request, None, platform_version) diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs index ce3f5eee587..ececc853c9c 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs @@ -65,7 +65,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation @@ -112,7 +112,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Fetch all documents diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs index dbcd83938b5..57fcf6ab630 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs @@ -84,7 +84,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs index d96e8fbb014..0f2e5b017e3 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs @@ -87,7 +87,7 @@ impl Drive { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 7dc217fed77..9ef360467d4 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -86,7 +86,7 @@ impl Drive { // canonicalization is equivalent to running it after. crate::query::validate_resolved_time_range_clause_shapes( &request.where_clauses, - &request.resolved_time_range_fields, + &request.resolved_time_ranges, )?; if request.prove { return self.execute_document_average_prove(request, transaction, platform_version); @@ -188,7 +188,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, - &request.resolved_time_range_fields, + &request.resolved_time_ranges, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { @@ -275,7 +275,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, - &request.resolved_time_range_fields, + &request.resolved_time_ranges, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { @@ -364,7 +364,7 @@ impl Drive { request.document_type.indexes(), &request.where_clauses, &request.sum_property, - &request.resolved_time_range_fields, + &request.resolved_time_ranges, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { @@ -614,7 +614,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -710,7 +710,7 @@ mod tests { limit: Some(over_max), prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let err = drive @@ -825,7 +825,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -903,7 +903,7 @@ mod tests { limit: None, prove: false, drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_request = DocumentSumRequest { contract, @@ -915,7 +915,7 @@ mod tests { limit: None, prove: false, drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1030,7 +1030,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let joint_response = drive @@ -1142,7 +1142,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let joint_response = drive @@ -1163,7 +1163,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1175,7 +1175,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1279,7 +1279,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let joint_response = drive @@ -1300,7 +1300,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1312,7 +1312,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1405,7 +1405,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let joint_response = drive @@ -1538,7 +1538,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let joint_response = drive @@ -1559,7 +1559,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let sum_request = DocumentSumRequest { contract: &data_contract, @@ -1571,7 +1571,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let count_resp = drive .execute_document_count_request(count_request, None, platform_version) @@ -1674,7 +1674,7 @@ mod tests { limit: Some(2), prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1753,7 +1753,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1832,7 +1832,7 @@ mod tests { limit: Some(4), prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1888,7 +1888,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let err = drive @@ -1951,7 +1951,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let err = drive @@ -2012,7 +2012,15 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec!["$createdAt".to_string()], + resolved_time_ranges: vec![crate::query::ResolvedTimeRange { + field: "$createdAt".to_string(), + transform: dpp::data_contract::document_type::TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 21_600, + step_seconds: 7_200, + phase_seconds: 0, + }, + }], }; let err = drive @@ -2108,7 +2116,7 @@ mod tests { limit: Some(2), prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -2238,7 +2246,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -2345,7 +2353,7 @@ mod tests { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive diff --git a/packages/rs-drive/src/query/drive_document_average_query/mod.rs b/packages/rs-drive/src/query/drive_document_average_query/mod.rs index bd51842ee07..ea73c2adbb3 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/mod.rs @@ -30,6 +30,7 @@ pub mod drive_dispatcher; #[cfg(feature = "server")] +use crate::query::ResolvedTimeRange; use crate::query::{OrderClause, WhereClause}; #[cfg(feature = "server")] @@ -117,10 +118,10 @@ pub struct DocumentAverageRequest<'a> { /// The fields among `where_clauses` whose equality clause was produced by /// `IN_TIME_RANGE` resolution rather than written by the caller. Same /// contract and same purpose as - /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]: /// it is what gates which indexes the sum pickers (which average rides) /// may select. - pub resolved_time_range_fields: Vec, + pub resolved_time_ranges: Vec, /// Structured order-clauses. pub order_clauses: Vec, /// The average mode requested. diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index e9da98f98e2..b6ca555aa66 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -111,10 +111,10 @@ impl Drive { // the catalog of rejections. let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; - let resolved_time_range_fields = request.resolved_time_range_fields; + let resolved_time_ranges = request.resolved_time_ranges; crate::query::validate_resolved_time_range_clause_shapes( &where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, )?; // Convert AverageMode → SumMode (1:1 by construction); sum's @@ -150,7 +150,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, transaction, platform_version, @@ -176,7 +176,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, order_by_ascending, per_in_limit as u16, @@ -221,7 +221,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, return_distinct, order_by_ascending, diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs index edf8f28d5d1..c4a3eeaef81 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -22,6 +22,7 @@ use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::{WhereClause, WhereOperator}; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -48,7 +49,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, left_to_right: bool, limit: u16, @@ -123,7 +124,7 @@ impl Drive { document_type.indexes(), &clauses_for_value, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index de1ebf3b00d..7fc349b0d0f 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -41,6 +41,7 @@ use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::{WhereClause, WhereOperator}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; @@ -71,7 +72,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, return_distinct: bool, left_to_right: bool, @@ -83,7 +84,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .filter(|idx| idx.range_countable) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs index 723578efac4..facc40b8d8d 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs @@ -32,6 +32,7 @@ use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -54,7 +55,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -90,7 +91,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .filter(|idx| idx.countable.is_countable()) .ok_or_else(|| { diff --git a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs index 1d696c3451a..17159966086 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs @@ -22,6 +22,7 @@ use super::{DocumentCountMode, DriveDocumentCountQuery, SplitCountEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -65,9 +66,9 @@ pub struct DocumentCountRequest<'a> { /// The fields among `where_clauses` whose equality clause was produced by /// `IN_TIME_RANGE` resolution rather than written by the caller. Same /// contract and same purpose as - /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]: /// it is what gates which indexes the count pickers may select. - pub resolved_time_range_fields: Vec, + pub resolved_time_ranges: Vec, /// Structured `order_by` clauses. The first clause's direction /// governs split-mode entry ordering (per-`In`-value / /// per-distinct-value-in-range) and, on the @@ -451,10 +452,10 @@ impl Drive { // for the catalog of rejections / canonicalization rules. let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; - let resolved_time_range_fields = request.resolved_time_range_fields; + let resolved_time_ranges = request.resolved_time_ranges; crate::query::validate_resolved_time_range_clause_shapes( &where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, )?; let order_clauses = request.order_clauses; @@ -487,7 +488,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, transaction, platform_version, )?; @@ -509,7 +510,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, options, transaction, platform_version, @@ -550,7 +551,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, options, transaction, platform_version, @@ -572,7 +573,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, transaction, platform_version, )?, @@ -632,7 +633,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, limit_u16, left_to_right, transaction, @@ -646,7 +647,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, transaction, platform_version, )?, @@ -734,7 +735,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, effective_limit, left_to_right, transaction, diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs index 00f071851df..3ab683cdf8d 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/per_in_value.rs @@ -9,6 +9,7 @@ use super::super::{DriveDocumentCountQuery, SplitCountEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -37,7 +38,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], options: RangeCountOptions, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -98,7 +99,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &clauses_for_value, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs index 9dc6c265e71..56b31399008 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/point_lookup_proof.rs @@ -9,6 +9,7 @@ use super::super::DriveDocumentCountQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -39,7 +40,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -71,7 +72,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs index 0514a43034b..4f236843b68 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_aggregate_carrier_proof.rs @@ -27,6 +27,7 @@ use super::super::DriveDocumentCountQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -55,7 +56,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], limit: Option, left_to_right: bool, transaction: TransactionArg, @@ -64,7 +65,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs index ec9dce8f109..de6841417fb 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_distinct_proof.rs @@ -10,6 +10,7 @@ use super::super::DriveDocumentCountQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -36,7 +37,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], limit: u16, left_to_right: bool, transaction: TransactionArg, @@ -45,7 +46,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs index 0d5c1bf1d42..998cdf8da00 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_no_proof.rs @@ -10,6 +10,7 @@ use super::super::{DriveDocumentCountQuery, SplitCountEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -26,7 +27,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], options: RangeCountOptions, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -34,7 +35,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs index d0d8decbb0f..9a8835fa9e6 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/range_proof.rs @@ -9,6 +9,7 @@ use super::super::DriveDocumentCountQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -25,14 +26,14 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { let index = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs index 4590324620b..74770889d79 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/executors/total.rs @@ -6,6 +6,7 @@ use super::super::{DriveDocumentCountQuery, SplitCountEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -25,7 +26,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -58,7 +59,7 @@ impl Drive { let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( document_type.indexes(), &where_clauses, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs index 7180916f3f5..06109a156da 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs @@ -7,6 +7,7 @@ use super::super::conditions::WhereClause; use super::DriveDocumentCountQuery; use crate::query::index_admissible_for_resolved_time_range; +use crate::query::ResolvedTimeRange; use dpp::data_contract::document_type::Index; use std::collections::{BTreeMap, BTreeSet}; @@ -35,7 +36,7 @@ impl DriveDocumentCountQuery<'_> { /// CountTree directly — that path doesn't use this picker because no /// index is involved. /// - /// `resolved_time_range_fields` names the fields whose equality clause was + /// `resolved_time_ranges` names the fields whose equality clause was /// produced by `IN_TIME_RANGE` resolution (see /// [`crate::query::resolve_time_range_bucket_clause`]); it gates which /// indexes are candidates at all — see @@ -43,7 +44,7 @@ impl DriveDocumentCountQuery<'_> { pub fn find_countable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> Option<&'b Index> { if Self::has_unsupported_operator(where_clauses) { return None; @@ -69,7 +70,7 @@ impl DriveDocumentCountQuery<'_> { // every document unless the query pins a single bucket, and only // a resolution-produced equality does that. Conversely a raw // clause must never bind to bucket keys. - if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) { continue; } if !index.countable.is_countable() { @@ -110,7 +111,7 @@ impl DriveDocumentCountQuery<'_> { /// (no range operator) should fall back to /// [`Self::find_countable_index_for_where_clauses`]. /// - /// `resolved_time_range_fields` gates the candidate set exactly as in + /// `resolved_time_ranges` gates the candidate set exactly as in /// [`Self::find_countable_index_for_where_clauses`]. A resolved field /// never arrives as a range clause — resolution always produces an /// equality — so with a non-empty list the only bucketed index this can @@ -120,7 +121,7 @@ impl DriveDocumentCountQuery<'_> { pub fn find_range_countable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> Option<&'b Index> { let range_clauses: Vec<&WhereClause> = where_clauses .iter() @@ -176,7 +177,7 @@ impl DriveDocumentCountQuery<'_> { // indexes store one entry per containing bucket, so only a query // pinned to a single bucket by a resolution-produced equality may // walk them, and raw clauses may never bind to bucket keys. - if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) { continue; } if !index.range_countable || !index.countable.is_countable() { diff --git a/packages/rs-drive/src/query/drive_document_count_query/path_query.rs b/packages/rs-drive/src/query/drive_document_count_query/path_query.rs index 8018b8367f2..041ade14dd4 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/path_query.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/path_query.rs @@ -223,7 +223,7 @@ impl DriveDocumentCountQuery<'_> { ), )); } - path.push(prop.name.as_bytes().to_vec()); + path.push(self.index.level_key_for_property(&prop.name).into_bytes()); path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -240,7 +240,11 @@ impl DriveDocumentCountQuery<'_> { ), ))? .name; - path.push(range_prop_name.as_bytes().to_vec()); + path.push( + self.index + .level_key_for_property(range_prop_name) + .into_bytes(), + ); Ok(PathQuery::new_aggregate_count_on_range(path, query_item)) } @@ -363,7 +367,7 @@ impl DriveDocumentCountQuery<'_> { )?; match (&carrier, clause.operator) { (Carrier::Pending, WhereOperator::Equal) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -371,15 +375,16 @@ impl DriveDocumentCountQuery<'_> { )?); } (Carrier::Pending, WhereOperator::In) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::In(clause.clone()); } (Carrier::Pending, op) if Self::is_range_operator(op) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::Range(clause.clone()); } (Carrier::In(_) | Carrier::Range(_), WhereOperator::Equal) => { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -404,7 +409,11 @@ impl DriveDocumentCountQuery<'_> { } } } - subquery_path_extension.push(terminator_prop_name.as_bytes().to_vec()); + subquery_path_extension.push( + self.index + .level_key_for_property(terminator_prop_name) + .into_bytes(), + ); let mut outer_query = Query::new_with_direction(left_to_right); match carrier { @@ -582,10 +591,11 @@ impl DriveDocumentCountQuery<'_> { platform_version, )?; if in_outer_keys.is_some() { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(serialized); } else { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(serialized); } } @@ -600,7 +610,7 @@ impl DriveDocumentCountQuery<'_> { } // Path stops at the In-bearing prop's property- // name subtree; outer Query lives at that level. - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); let in_values = clause.in_values().into_data_with_error()??; let mut keys: Vec> = in_values .iter() @@ -843,7 +853,11 @@ impl DriveDocumentCountQuery<'_> { let mut in_outer_keys: Option>> = None; let mut subquery_path_extension: Vec> = vec![]; - for prop in self.index.properties.iter() { + for (position, prop) in self.index.properties.iter().enumerate() { + // The path segment is the level key — grid-qualified for a + // time-range index's first property — while the clause lookup + // and value serialization stay on the bare property name. + let level_key = self.index.level_key(position, &prop.name); let clause = self .where_clauses .iter() @@ -871,10 +885,10 @@ impl DriveDocumentCountQuery<'_> { // path. Any number of these may accumulate — // one for each Equal that sits *after* the In // in the index ordering. - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension.push(level_key.as_bytes().to_vec()); subquery_path_extension.push(serialized); } else { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(level_key.as_bytes().to_vec()); base_path.push(serialized); } } @@ -891,7 +905,7 @@ impl DriveDocumentCountQuery<'_> { // property-name subtree; outer Query lives at // that level. Any trailing Equal property then // routes through `subquery_path_extension`. - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); let in_values = clause.in_values().into_data_with_error()??; let mut keys: Vec> = in_values .iter() diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index 7a258679fea..568efe640b6 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -541,7 +541,7 @@ fn test_aggregate_count_in_fan_out_ignores_default_query_limit() { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1345,7 +1345,7 @@ fn test_compound_range_in_summed_no_proof_uses_per_in_aggregate_fanout() { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1420,7 +1420,7 @@ fn test_count_request_with_duplicate_equality_clauses_is_rejected() { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let err = drive @@ -1617,7 +1617,7 @@ fn test_range_distinct_proof_uses_compile_time_default_query_limit_not_operator_ limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -1742,7 +1742,7 @@ fn test_range_distinct_no_proof_rejects_zero_effective_limit() { limit: None, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let result = drive.execute_document_count_request(request, None, platform_version); @@ -3473,7 +3473,7 @@ mod time_range_picker_tests { source: SOURCE.to_string(), range_seconds: 6 * HOUR_SECONDS, step_seconds: 2 * HOUR_SECONDS, - origin_seconds: 0, + phase_seconds: 0, }), ), ] @@ -3482,6 +3482,22 @@ mod time_range_picker_tests { .collect() } + /// The provenance a real `IN_TIME_RANGE` resolution against the + /// `trending` grid produces: the source field plus the exact transform. + /// Constructed directly (not read from the candidate map) so tests that + /// remove the trending index can still present the resolution. + fn source_resolution() -> Vec { + vec![crate::query::ResolvedTimeRange { + field: SOURCE.to_string(), + transform: TimeRangeTransform { + source: SOURCE.to_string(), + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 2 * HOUR_SECONDS, + phase_seconds: 0, + }, + }] + } + fn equal(field: &str, value: Value) -> WhereClause { WhereClause { field: field.to_string(), @@ -3502,7 +3518,7 @@ mod time_range_picker_tests { let picked = DriveDocumentCountQuery::find_countable_index_for_where_clauses( &indexes, &where_clauses, - &[SOURCE.to_string()], + &source_resolution(), ) .expect("the bucketed index exactly covers the resolved clause set"); assert_eq!(picked.name, "trending"); @@ -3575,7 +3591,7 @@ mod time_range_picker_tests { DriveDocumentCountQuery::find_countable_index_for_where_clauses( &indexes, &where_clauses, - &[SOURCE.to_string()], + &source_resolution(), ) .is_none() ); diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs index 5743d178a7a..912f4ab4a70 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/drive_dispatcher.rs @@ -21,6 +21,7 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::having::HavingClause; use crate::query::projection::SelectProjection; +use crate::query::ResolvedTimeRange; use crate::query::{OrderClause, WhereClause}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -67,14 +68,14 @@ pub struct DocumentRankedRequest<'a> { pub where_clauses: &'a [WhereClause], /// The fields among `where_clauses` whose equality clause was produced by /// `IN_TIME_RANGE` resolution (see - /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]). + /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]). /// Must be empty: `where_clauses` must be empty, so there is nothing to /// have resolved, and ranking over bucket keys is undesigned — a document /// belongs to `overlap_factor` buckets at once, so it would contribute to /// that many groups. Carried (and rejected) here for the same reason /// `where_clauses` is: drive owns the rejection regardless of which /// upstream path built the request. - pub resolved_time_range_fields: &'a [String], + pub resolved_time_ranges: &'a [ResolvedTimeRange], /// Request `limit` — the ranking's `k`. **Required**; there is no /// server default a verifying client could reproduce. pub limit: Option, @@ -143,7 +144,7 @@ impl Drive { // enforces below — a resolved equality is a where clause — but stated // here so the ranked surface's exclusion of bucketed indexes is a // rejection rather than a silent fallback to another index. - if !request.resolved_time_range_fields.is_empty() { + if !request.resolved_time_ranges.is_empty() { return Err(Error::Query(QuerySyntaxError::Unsupported( "a ranked query cannot carry a time-range (IN_TIME_RANGE) selection: ranking \ groups by an index's only property, and a document belongs to every bucket \ diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs b/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs index 9b503d658a4..5298bd31cb3 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/tests.rs @@ -962,7 +962,7 @@ fn run( offset: case.offset, has_start_at: false, prove, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }, None, platform_version(), @@ -2249,7 +2249,7 @@ mod pinned_prefix { offset: None, has_start_at: false, prove, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }, None, platform_version(), @@ -2467,7 +2467,7 @@ mod pinned_prefix { offset: None, has_start_at: false, prove, - resolved_time_range_fields: &[], + resolved_time_ranges: &[], }; let page = match drive diff --git a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs index c7a8c483802..fd6f11d52fc 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs @@ -62,13 +62,13 @@ impl Drive { let contract_id = request.contract.id().to_buffer(); let document_type_name = request.document_type.name().to_string(); let where_clauses = request.where_clauses; - let resolved_time_range_fields = request.resolved_time_range_fields; + let resolved_time_ranges = request.resolved_time_ranges; // Same provenance-vs-shape contract as the count and joint // dispatchers; sum has no canonicalize step, so the guard anchors // here. crate::query::validate_resolved_time_range_clause_shapes( &where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, )?; let sum_property = request.sum_property; // Default direction is ascending; the first order clause's @@ -86,7 +86,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, transaction, platform_version, @@ -106,7 +106,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, options, transaction, @@ -137,7 +137,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, options, transaction, @@ -156,7 +156,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, transaction, platform_version, @@ -208,7 +208,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, limit_u16, order_by_ascending, @@ -223,7 +223,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, transaction, platform_version, @@ -268,7 +268,7 @@ impl Drive { request.document_type, document_type_name, where_clauses, - &resolved_time_range_fields, + &resolved_time_ranges, sum_property, limit_u16, order_by_ascending, diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs index 219320ece8e..e54dedd6187 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/per_in_value.rs @@ -11,6 +11,7 @@ use super::super::{DriveDocumentSumQuery, RangeSumOptions, SumEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::{WhereClause, WhereOperator}; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -29,7 +30,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, options: RangeSumOptions, transaction: TransactionArg, @@ -83,7 +84,7 @@ impl Drive { document_type.indexes(), &clauses_for_value, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs index 8c8ea73514c..2de39655fa6 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/point_lookup_proof.rs @@ -6,6 +6,7 @@ use super::super::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -25,7 +26,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -60,7 +61,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs index c50666d36e5..305348b28a4 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_aggregate_carrier_proof.rs @@ -15,6 +15,7 @@ use super::super::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -45,7 +46,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, limit: Option, left_to_right: bool, @@ -56,7 +57,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs index 786956442c7..d448b7a8f83 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_distinct_proof.rs @@ -7,6 +7,7 @@ use super::super::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -26,7 +27,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, limit: u16, left_to_right: bool, @@ -37,7 +38,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs index 6c7038c5d22..deffdc15c58 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_no_proof.rs @@ -6,6 +6,7 @@ use super::super::{DriveDocumentSumQuery, RangeSumOptions, SumEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -23,7 +24,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, options: RangeSumOptions, transaction: TransactionArg, @@ -33,7 +34,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs index d793e0fa96d..b166203ea8e 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/range_proof.rs @@ -8,6 +8,7 @@ use super::super::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -25,7 +26,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -34,7 +35,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs index 70e428aa818..c872b803b38 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/executors/total.rs @@ -10,6 +10,7 @@ use super::super::{DriveDocumentSumQuery, SumEntry}; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::ResolvedTimeRange; use crate::query::WhereClause; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -30,7 +31,7 @@ impl Drive { document_type: DocumentTypeRef, document_type_name: String, where_clauses: Vec, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], sum_property: String, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -66,7 +67,7 @@ impl Drive { document_type.indexes(), &where_clauses, &sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs index 6d6f5a6a22d..0d3c5251e7f 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/index_picker.rs @@ -15,6 +15,7 @@ //! prover and verifier reject the same set of inputs (same as count). use crate::query::drive_document_sum_query::{is_indexable_for_sum, is_range_operator}; +use crate::query::ResolvedTimeRange; use crate::query::{index_admissible_for_resolved_time_range, WhereClause, WhereOperator}; use dpp::data_contract::document_type::Index; use std::collections::{BTreeMap, BTreeSet}; @@ -27,7 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; /// additional `summable == Some(sum_property)` predicate on top of the /// strict-coverage match. /// -/// `resolved_time_range_fields` names the fields whose equality clause was +/// `resolved_time_ranges` names the fields whose equality clause was /// produced by `IN_TIME_RANGE` resolution (see /// [`crate::query::resolve_time_range_bucket_clause`]) and gates which indexes /// are candidates — see [`index_admissible_for_resolved_time_range`]. @@ -35,7 +36,7 @@ pub fn find_summable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], sum_property: &str, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> Option<&'b Index> { // Defense-in-depth: any non-indexable operator immediately disqualifies // — the sum point-lookup path can only serve Equal/In. @@ -62,7 +63,7 @@ pub fn find_summable_index_for_where_clauses<'b>( // every document unless the query pins a single bucket, and only a // resolution-produced equality does that. Conversely a raw clause // must never bind to bucket keys. - if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) { continue; } // Skip if not summable OR if summable property doesn't match. @@ -92,7 +93,7 @@ pub fn find_summable_index_for_where_clauses<'b>( /// /// Mirror of count's `find_range_countable_index_for_where_clauses`. /// -/// `resolved_time_range_fields` gates the candidate set exactly as in +/// `resolved_time_ranges` gates the candidate set exactly as in /// [`find_summable_index_for_where_clauses`]. A resolved field never arrives /// as a range clause — resolution always produces an equality — so with a /// non-empty list the only bucketed index this can return is one whose @@ -103,7 +104,7 @@ pub fn find_range_summable_index_for_where_clauses<'b>( indexes: &'b BTreeMap, where_clauses: &[WhereClause], sum_property: &str, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> Option<&'b Index> { let range_clauses: Vec<&WhereClause> = where_clauses .iter() @@ -148,7 +149,7 @@ pub fn find_range_summable_index_for_where_clauses<'b>( // indexes store one entry per containing bucket, so only a query // pinned to a single bucket by a resolution-produced equality may // walk them, and raw clauses may never bind to bucket keys. - if !index_admissible_for_resolved_time_range(index, resolved_time_range_fields) { + if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) { continue; } if !index.range_summable { diff --git a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs index 928307224ea..03b55e21329 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs @@ -54,6 +54,7 @@ pub mod executors; mod tests; #[cfg(any(feature = "server", feature = "verify"))] +use crate::query::ResolvedTimeRange; use crate::query::{WhereClause, WhereOperator}; #[cfg(any(feature = "server", feature = "verify"))] @@ -183,9 +184,9 @@ pub struct DocumentSumRequest<'a> { /// The fields among `where_clauses` whose equality clause was produced by /// `IN_TIME_RANGE` resolution rather than written by the caller. Same /// contract and same purpose as - /// [`crate::query::DriveDocumentQuery::resolved_time_range_fields`]: + /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]: /// it is what gates which indexes the sum pickers may select. - pub resolved_time_range_fields: Vec, + pub resolved_time_ranges: Vec, /// Structured order-clauses (parsed via /// [`drive_dispatcher::order_clauses_from_value`]). pub order_clauses: Vec, diff --git a/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs b/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs index 7ee7bf94a88..39a1ff70b3f 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/path_query.rs @@ -22,6 +22,7 @@ use crate::drive::RootTree; use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::drive_document_sum_query::{is_range_operator, DriveDocumentSumQuery}; +use crate::query::ResolvedTimeRange; use crate::query::{WhereClause, WhereOperator}; // `serialize_value_for_key` is a `DocumentTypeV0Methods` method, NOT // `DocumentTypeBasicMethods` (which is the trait of versionless basic @@ -112,10 +113,11 @@ impl<'a> DriveDocumentSumQuery<'a> { platform_version, )?; if in_outer_keys.is_some() { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(serialized); } else { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(serialized); } } @@ -128,7 +130,7 @@ impl<'a> DriveDocumentSumQuery<'a> { ), )); } - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); let in_values = clause.in_values().into_data_with_error()??; let mut keys: Vec> = in_values .iter() @@ -285,7 +287,7 @@ impl<'a> DriveDocumentSumQuery<'a> { ), )); } - path.push(prop.name.as_bytes().to_vec()); + path.push(self.index.level_key_for_property(&prop.name).into_bytes()); path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -302,7 +304,11 @@ impl<'a> DriveDocumentSumQuery<'a> { ), ))? .name; - path.push(range_prop_name.as_bytes().to_vec()); + path.push( + self.index + .level_key_for_property(range_prop_name) + .into_bytes(), + ); // grovedb PR 670 surface: `Query::new_aggregate_sum_on_range`. let query = Query::new_aggregate_sum_on_range(query_item); @@ -371,7 +377,7 @@ impl<'a> DriveDocumentSumQuery<'a> { ), )); } - path.push(prop.name.as_bytes().to_vec()); + path.push(self.index.level_key_for_property(&prop.name).into_bytes()); path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -388,7 +394,11 @@ impl<'a> DriveDocumentSumQuery<'a> { ), ))? .name; - path.push(range_prop_name.as_bytes().to_vec()); + path.push( + self.index + .level_key_for_property(range_prop_name) + .into_bytes(), + ); let query = grovedb::Query::new_aggregate_count_and_sum_on_range(query_item); Ok(PathQuery::new( @@ -602,10 +612,11 @@ impl<'a> DriveDocumentSumQuery<'a> { platform_version, )?; if in_outer_keys.is_some() { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(serialized); } else { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(serialized); } } @@ -620,7 +631,7 @@ impl<'a> DriveDocumentSumQuery<'a> { } // Path stops at the In-bearing prop's property- // name subtree; outer Query lives at that level. - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); let in_values = clause.in_values().into_data_with_error()??; let mut keys: Vec> = in_values .iter() @@ -813,7 +824,7 @@ impl<'a> DriveDocumentSumQuery<'a> { ))?; match (&carrier, clause.operator) { (Carrier::Pending, WhereOperator::Equal) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -821,15 +832,16 @@ impl<'a> DriveDocumentSumQuery<'a> { )?); } (Carrier::Pending, WhereOperator::In) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::In(clause.clone()); } (Carrier::Pending, op) if is_range_operator(op) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::Range(clause.clone()); } (Carrier::In(_) | Carrier::Range(_), WhereOperator::Equal) => { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -854,7 +866,11 @@ impl<'a> DriveDocumentSumQuery<'a> { } } } - subquery_path_extension.push(terminator_prop_name.as_bytes().to_vec()); + subquery_path_extension.push( + self.index + .level_key_for_property(terminator_prop_name) + .into_bytes(), + ); let mut outer_query = Query::new_with_direction(left_to_right); match carrier { @@ -1001,7 +1017,7 @@ impl<'a> DriveDocumentSumQuery<'a> { ))?; match (&carrier, clause.operator) { (Carrier::Pending, WhereOperator::Equal) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); base_path.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -1009,15 +1025,16 @@ impl<'a> DriveDocumentSumQuery<'a> { )?); } (Carrier::Pending, WhereOperator::In) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::In(clause.clone()); } (Carrier::Pending, op) if is_range_operator(op) => { - base_path.push(prop.name.as_bytes().to_vec()); + base_path.push(self.index.level_key_for_property(&prop.name).into_bytes()); carrier = Carrier::Range(clause.clone()); } (Carrier::In(_) | Carrier::Range(_), WhereOperator::Equal) => { - subquery_path_extension.push(prop.name.as_bytes().to_vec()); + subquery_path_extension + .push(self.index.level_key_for_property(&prop.name).into_bytes()); subquery_path_extension.push(self.document_type.serialize_value_for_key( prop.name.as_str(), &clause.value, @@ -1043,7 +1060,11 @@ impl<'a> DriveDocumentSumQuery<'a> { } } } - subquery_path_extension.push(terminator_prop_name.as_bytes().to_vec()); + subquery_path_extension.push( + self.index + .level_key_for_property(terminator_prop_name) + .into_bytes(), + ); let mut outer_query = Query::new_with_direction(left_to_right); match carrier { @@ -1107,7 +1128,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], platform_version: &PlatformVersion, ) -> Result { use crate::query::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; @@ -1118,7 +1139,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( @@ -1147,7 +1168,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], platform_version: &PlatformVersion, ) -> Result { use crate::query::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; @@ -1158,7 +1179,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( @@ -1193,7 +1214,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type: DocumentTypeRef, sum_property: &str, where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], limit: Option, left_to_right: bool, platform_version: &PlatformVersion, @@ -1206,7 +1227,7 @@ impl<'a> DriveDocumentSumQuery<'a> { document_type.indexes(), where_clauses, sum_property, - resolved_time_range_fields, + resolved_time_ranges, ) .ok_or_else(|| { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( diff --git a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs index 1b1355916f0..2aed0b93f20 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs @@ -469,7 +469,7 @@ mod limit_policy_regression { limit: None, prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let response = drive @@ -567,7 +567,7 @@ mod limit_policy_regression { limit: Some(over_max), prove: true, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let err = drive @@ -639,7 +639,7 @@ mod limit_policy_regression { limit, prove: false, drive_config: &drive_config, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; for (requested, expected) in [(None, 2), (Some(1), 1), (Some(10_000), 3)] { diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index f9434041666..bb180af8224 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::TimeRangeTransform; use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] @@ -611,8 +612,55 @@ impl TimeRangeSelector { } } +/// A concrete grid specification, matching a contract's `timeRange` +/// declaration verbatim (`range` / `step` / `phase`, in seconds). +/// +/// The structured `IN_TIME_RANGE` operand carries one of these when the +/// queried field is bucketed by more than one grid: the bare selector +/// (`"newest"` / `"oldest"`) is unambiguous only while exactly one time-range +/// index exists on the field, so a multi-grid field requires the query to +/// name the grid it wants. +#[cfg(any(feature = "server", feature = "verify"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TimeRangeGridSpec { + /// Window length in seconds, as the contract declares it. + pub range_seconds: u64, + /// Interval between window starts in seconds, as the contract declares it. + pub step_seconds: u64, + /// Grid alignment phase in seconds (0 when the contract omits `phase`). + pub phase_seconds: u64, +} + +#[cfg(any(feature = "server", feature = "verify"))] +impl TimeRangeGridSpec { + /// Whether this spec names exactly the given transform's grid. + pub fn matches(&self, transform: &TimeRangeTransform) -> bool { + self.range_seconds == transform.range_seconds + && self.step_seconds == transform.step_seconds + && self.phase_seconds == transform.phase_seconds + } +} + +/// Resolution provenance for one `IN_TIME_RANGE` clause: the field the +/// selector named and the exact grid the resolution used. Recorded by the +/// resolver's caller on the query (see +/// [`DriveDocumentQuery::resolved_time_ranges`]) and consumed by the index +/// pickers through [`index_admissible_for_resolved_time_range`], which pins +/// selection to the index carrying exactly this grid — a field may be +/// bucketed by several grids, so the field name alone no longer identifies +/// the index the resolution was computed against. +#[cfg(any(feature = "server", feature = "verify"))] +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedTimeRange { + /// The bucketed source field the resolved equality is on. + pub field: String, + /// The grid the bucket start was computed from. + pub transform: TimeRangeTransform, +} + /// Resolves a time-range selection on `field` into a concrete equality -/// [`WhereClause`] on the bucketed source field, using the index's +/// [`WhereClause`] on the bucketed source field, using the named grid's /// `timeRange` transform and an authoritative `block_time_ms`. /// /// The server supplies `block_time_ms` from current block time and the @@ -621,98 +669,137 @@ impl TimeRangeSelector { /// index/count proofs apply unchanged and the engine never needs a dedicated /// time-range operator. /// +/// `grid` selects among several time-range indexes on the same field: `None` +/// is accepted only while exactly one grid buckets the field (the common +/// case); with two or more grids the caller must name one, and naming a grid +/// no index declares is an error either way. +/// /// What comes back is an ordinary equality clause, byte-identical to one a -/// client could have written by hand against a raw timestamp. The fact that it -/// was *produced here* is what makes it safe to run against an index whose keys -/// are bucket starts, and that provenance is not recoverable from the clause: -/// callers must record `field` in the query's `resolved_time_range_fields` -/// (see [`DriveDocumentQuery::resolved_time_range_fields`]), which +/// client could have written by hand against a raw timestamp, plus the +/// [`ResolvedTimeRange`] provenance callers must record on the query (see +/// [`DriveDocumentQuery::resolved_time_ranges`]), which /// [`DriveDocumentQuery::find_best_index`] and the aggregate index pickers /// consume through [`index_admissible_for_resolved_time_range`] to pin -/// selection to the bucketed index — and to keep raw queries off it. +/// selection to the grid's index — and to keep raw queries off it. #[cfg(any(feature = "server", feature = "verify"))] pub fn resolve_time_range_bucket_clause( field: &str, selector: TimeRangeSelector, + grid: Option, document_type: DocumentTypeRef, block_time_ms: u64, -) -> Result { - // Every index bucketing `field` uses the same transform for it — the - // transform's source must be the index's first property, and two indexes - // bucketing the same field with different windows would be two different - // sources of truth for one clause — so the first match is the transform. - let transform = document_type - .indexes() - .values() - .find_map(|index| { - index - .time_range - .as_ref() - .filter(|transform| transform.source == field) - }) - .ok_or(Error::Query( +) -> Result<(WhereClause, ResolvedTimeRange), Error> { + // Distinct grids bucketing `field` — several indexes may share one grid + // (they share the storage level too), so dedupe by transform. + let mut grids: Vec<&TimeRangeTransform> = Vec::new(); + for index in document_type.indexes().values() { + if let Some(transform) = index + .time_range + .as_ref() + .filter(|transform| transform.source == field) + { + if !grids.contains(&transform) { + grids.push(transform); + } + } + } + if grids.is_empty() { + return Err(Error::Query( QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( "no time-range index is defined on field \"{}\"", field )), - ))?; + )); + } + + let transform = match grid { + Some(spec) => *grids + .iter() + .find(|transform| spec.matches(transform)) + .ok_or(Error::Query(QuerySyntaxError::Unsupported(format!( + "no time-range index on \"{}\" declares the grid range={}s step={}s phase={}s", + field, spec.range_seconds, spec.step_seconds, spec.phase_seconds + ))))?, + None => { + if grids.len() > 1 { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "field \"{}\" is bucketed by {} different grids; the IN_TIME_RANGE operand \ + must name one as [selector, range, step] or [selector, range, step, phase] \ + (seconds, as the contract declares them)", + field, + grids.len() + )))); + } + grids[0] + } + }; let bucket_start = match selector { TimeRangeSelector::Newest => transform.newest_active_start(block_time_ms), TimeRangeSelector::Oldest => transform.oldest_active_start(block_time_ms), } .ok_or(Error::Query(QuerySyntaxError::Unsupported(format!( - "no time range on \"{}\" is active yet: the current block time predates the index's \ - origin", + "no time range on \"{}\" is active yet: the block time predates the grid's phase \ + anchor (only possible within the first step after the epoch)", field ))))?; - Ok(WhereClause { - field: field.to_string(), - operator: WhereOperator::Equal, - value: Value::U64(bucket_start), - }) + Ok(( + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::U64(bucket_start), + }, + ResolvedTimeRange { + field: field.to_string(), + transform: transform.clone(), + }, + )) } /// Whether `index` may serve a query whose equality clauses on -/// `resolved_time_range_fields` were produced by +/// `resolved_time_ranges` were produced by /// [`resolve_time_range_bucket_clause`]. /// /// A time-range index does not store the source field's raw values: under its -/// first property it stores bucket *starts*, and one document is stored once -/// per bucket that contains its timestamp. So the two kinds of index are not -/// interchangeable in either direction, and both mismatches are silent — -/// they return a validly-proven wrong answer rather than an error: +/// grid-qualified first level it stores bucket *starts*, and one document is +/// stored once per bucket that contains its timestamp. So a bucketed index, a +/// raw index and another grid's bucketed index are never interchangeable, and +/// every mismatch is silent — a validly-proven wrong answer rather than an +/// error: /// -/// - A raw query (`resolved_time_range_fields` empty) that landed on a -/// bucketed index would compare a real timestamp against bucket starts and -/// see nothing (or, for range/IN shapes, walk overlapping buckets and count -/// the same document up to `overlap_factor` times). +/// - A raw query (`resolved_time_ranges` empty) that landed on a bucketed +/// index would compare a real timestamp against bucket starts and see +/// nothing (or, for range/IN shapes, walk overlapping buckets and count the +/// same document up to `overlap_factor` times). /// - A resolved query that landed on a raw index would compare a bucket start /// against real timestamps and see nothing. +/// - A resolved query that landed on a *different grid's* index would compare +/// one grid's bucket start against another grid's — every 6-hour start is +/// also a 3-hour start, so this can silently return the wrong window. /// -/// Hence the rule: with no resolved field only non-bucketed indexes are -/// admissible, and with one resolved field only the index bucketing exactly -/// that field is. Two resolved fields can never be served by a single index — -/// a transform's source must be its index's first property, so one index -/// buckets exactly one field — and are rejected by the caller. +/// Hence the rule: with no resolution only non-bucketed indexes are +/// admissible, and with one resolution only an index bucketing exactly that +/// field *with exactly that grid* is. Two resolutions can never be served by +/// a single index — a transform's source must be its index's first property, +/// so one index buckets exactly one field — and are rejected by the caller. #[cfg(any(feature = "server", feature = "verify"))] pub fn index_admissible_for_resolved_time_range( index: &Index, - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> bool { - match resolved_time_range_fields { + match resolved_time_ranges { [] => index.time_range.is_none(), - [field] => index + [resolved] => index .time_range .as_ref() - .is_some_and(|transform| transform.source == *field), + .is_some_and(|transform| *transform == resolved.transform), _ => false, } } /// Rejects a query whose resolution provenance and clause shapes disagree: -/// every field in `resolved_time_range_fields` must appear in the where +/// every field in `resolved_time_ranges` must appear in the where /// clauses as exactly one `Equal` clause — the only shape /// [`resolve_time_range_bucket_clause`] produces. /// @@ -728,9 +815,9 @@ pub fn index_admissible_for_resolved_time_range( #[cfg(any(feature = "server", feature = "verify"))] pub fn validate_resolved_time_range_clause_shapes( where_clauses: &[WhereClause], - resolved_time_range_fields: &[String], + resolved_time_ranges: &[ResolvedTimeRange], ) -> Result<(), Error> { - for field in resolved_time_range_fields { + for field in resolved_time_ranges.iter().map(|resolved| &resolved.field) { let mut equalities = 0usize; for clause in where_clauses.iter().filter(|c| &c.field == field) { if clause.operator == WhereOperator::Equal { @@ -793,7 +880,7 @@ pub struct DriveDocumentQuery<'a> { /// and cannot be told apart from a raw-timestamp lookup once built. /// /// Empty for every raw query. - pub resolved_time_range_fields: Vec, + pub resolved_time_ranges: Vec, } impl<'a> DriveDocumentQuery<'a> { @@ -824,7 +911,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } @@ -841,7 +928,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: true, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } @@ -862,7 +949,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at: None, start_at_included: true, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } @@ -1089,7 +1176,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }) } @@ -1236,7 +1323,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }) } @@ -1401,7 +1488,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at, start_at_included, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }) } @@ -1821,7 +1908,7 @@ impl<'a> DriveDocumentQuery<'a> { /// lowering, since the v0 lowering rejects them first. /// /// Selection is restricted to the indexes admissible for this query's - /// [`Self::resolved_time_range_fields`]: a query carrying an + /// [`Self::resolved_time_ranges`]: a query carrying an /// `IN_TIME_RANGE`-resolved equality may only be served by the index that /// buckets that field, and a raw query may never be served by a bucketed /// index. See [`index_admissible_for_resolved_time_range`] for why either @@ -1833,11 +1920,11 @@ impl<'a> DriveDocumentQuery<'a> { // equalities. Serving such a query would need a join across two // bucketed indexes, which the engine has no shape for. This runs // before any routing so the multiple-`In` path cannot bypass it. - if self.resolved_time_range_fields.len() > 1 { + if self.resolved_time_ranges.len() > 1 { return Err(Error::Query(QuerySyntaxError::Unsupported(format!( "at most one time-range selection (IN_TIME_RANGE) is supported per query; this \ one resolves {:?}, and no single index can bucket more than one field", - self.resolved_time_range_fields + self.resolved_time_ranges )))); } @@ -1848,7 +1935,11 @@ impl<'a> DriveDocumentQuery<'a> { // Enforce the resolved-field contract here instead: the resolved // equality must be present, and the bucketed source must not also // carry an `In`, a range, or an ordering. - if let Some(source) = self.resolved_time_range_fields.first() { + if let Some(source) = self + .resolved_time_ranges + .first() + .map(|resolved| &resolved.field) + { let has_equality_on_source = self.internal_clauses.equal_clauses.contains_key(source); let in_or_range_on_source = self @@ -1920,26 +2011,22 @@ impl<'a> DriveDocumentQuery<'a> { fields.as_slice(), in_field, order_by_keys.as_slice(), - |index| { - index_admissible_for_resolved_time_range( - index, - &self.resolved_time_range_fields, - ) - }, + |index| index_admissible_for_resolved_time_range(index, &self.resolved_time_ranges), platform_version, )? - .ok_or_else(|| match self.resolved_time_range_fields.first() { + .ok_or_else(|| match self.resolved_time_ranges.first() { // A time-range query is only servable by the index that - // buckets the field, so "no index" here is a narrower fact - // than the generic case: some index buckets the field (the - // clause could not have been resolved otherwise), but none - // that does also covers the rest of the query. - Some(field) => { + // buckets the field with the resolved grid, so "no index" + // here is a narrower fact than the generic case: some index + // buckets the field (the clause could not have been resolved + // otherwise), but none with that grid also covers the rest + // of the query. + Some(resolved) => { Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( - "a time-range query on \"{}\" requires an index that buckets it AND \ - covers the query's other where and order-by fields; valid indexes \ - are: {:?}", - field, + "a time-range query on \"{}\" requires an index that buckets it with \ + the resolved grid AND covers the query's other where and order-by \ + fields; valid indexes are: {:?}", + resolved.field, self.document_type.indexes() ))) } @@ -2719,7 +2806,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let path_query = query_asc @@ -3102,9 +3189,18 @@ mod tests { #[test] fn resolved_time_range_shape_guard_accepts_only_the_single_resolution_equality() { - use crate::query::validate_resolved_time_range_clause_shapes; + use crate::query::{validate_resolved_time_range_clause_shapes, ResolvedTimeRange}; + use dpp::data_contract::document_type::TimeRangeTransform; - let resolved = vec!["$createdAt".to_string()]; + let resolved = vec![ResolvedTimeRange { + field: "$createdAt".to_string(), + transform: TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 21_600, + step_seconds: 7_200, + phase_seconds: 0, + }, + }]; let equality = WhereClause { field: "$createdAt".to_string(), operator: WhereOperator::Equal, @@ -3194,7 +3290,7 @@ mod tests { start_at: Some([3u8; 32]), start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // Create a document that we are starting at, which may be missing 'transactionIndex' @@ -3311,7 +3407,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], } } diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs index 7e69a445f21..f68efb07c47 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs @@ -230,14 +230,21 @@ impl<'a> DriveDocumentQuery<'a> { .map_err(Error::from)?; let mut path = document_type_path; + // Path segments are level keys: grid-qualified for a time-range + // index's first property (`Index::level_key_for_property`), the bare + // property name everywhere else. for (intermediate_index, intermediate_value) in index.properties[..equality_len] .iter() .zip(intermediate_values.iter()) { - path.push(intermediate_index.name.as_bytes().to_vec()); + path.push( + index + .level_key_for_property(&intermediate_index.name) + .into_bytes(), + ); path.push(intermediate_value.as_slice().to_vec()); } - path.push(child_field.as_bytes().to_vec()); + path.push(index.level_key_for_property(&child_field).into_bytes()); Ok(PathQuery::new( path, @@ -298,7 +305,7 @@ impl<'a> DriveDocumentQuery<'a> { // query. See `index_admissible_for_resolved_time_range`. if !crate::query::index_admissible_for_resolved_time_range( index, - &self.resolved_time_range_fields, + &self.resolved_time_ranges, ) { continue; } diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs index 891b87b7918..2b6d106c62f 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs @@ -602,14 +602,22 @@ impl<'a> DriveDocumentQuery<'a> { let mut path = document_type_path; + // Path segments are level keys: grid-qualified for a time-range + // index's first property (`Index::level_key_for_property`), the bare + // property name everywhere else. The values pushed between them are + // untouched — a bucket start is encoded exactly like a timestamp. for (intermediate_index, intermediate_value) in intermediate_indexes.iter().zip(intermediate_values.iter()) { - path.push(intermediate_index.name.as_bytes().to_vec()); + path.push( + index + .level_key_for_property(&intermediate_index.name) + .into_bytes(), + ); path.push(intermediate_value.as_slice().to_vec()); } - path.push(last_index.name.as_bytes().to_vec()); + path.push(index.level_key_for_property(&last_index.name).into_bytes()); Ok(PathQuery::new( path, diff --git a/packages/rs-drive/src/verify/document/verify_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_proof/mod.rs index 301ebddad6b..d7a383f4220 100644 --- a/packages/rs-drive/src/verify/document/verify_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof/mod.rs @@ -77,7 +77,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let result = query.verify_proof(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs index 20e2447fd24..b19f32d2693 100644 --- a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs @@ -82,7 +82,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let result = query.verify_proof_keep_serialized(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs index 2059c084305..854902b78c1 100644 --- a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs @@ -94,7 +94,7 @@ mod tests { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let result = diff --git a/packages/rs-drive/tests/query_tests.rs b/packages/rs-drive/tests/query_tests.rs index 8d5789611e6..31c72050120 100644 --- a/packages/rs-drive/tests/query_tests.rs +++ b/packages/rs-drive/tests/query_tests.rs @@ -9055,7 +9055,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; // The current shape: the In clause in in_clauses @@ -9077,7 +9077,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at: None, start_at_included: false, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; for protocol_version in [13u32, 14u32] { diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9c5fb9d356b..4d526efcabb 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -96,15 +96,20 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 5. **Time-range indexes**: an index can declare a `timeRange` transform /// that buckets a required system timestamp (`$createdAt` / /// `$updatedAt` / `$transferredAt`) into fixed-length, regularly-spaced, -/// optionally overlapping windows declared in seconds. A document is -/// stored once per containing bucket (the v2 insert/delete and v1 -/// update walkers carry the fan-out; the per-document write -/// amplification is capped by `SystemLimits:: -/// max_time_range_overlap_factor`), and the v1 `getDocuments` handler -/// resolves the new `IN_TIME_RANGE` operator into a bucket-start -/// equality from committed block time, making "newest window" -/// trending/leaderboard document and count/sum/avg queries provable. -/// `unique: true` is admitted only for non-overlapping windows +/// optionally overlapping windows declared in seconds (`range` / `step`, +/// plus an optional `phase < step` alignment offset). Each grid gets its +/// own index subtree — the level is keyed by the property name qualified +/// with the grid — so several grids may bucket one timestamp side by +/// side. A document is stored once per containing bucket per grid (the +/// v2 insert/delete and v1 update walkers carry the fan-out; the +/// per-document write amplification is capped per index by +/// `SystemLimits::max_time_range_overlap_factor`), and the v1 +/// `getDocuments` handler resolves the new `IN_TIME_RANGE` operator — +/// bare `"newest"`/`"oldest"` on a single-grid field, or a structured +/// `[selector, range, step(, phase)]` operand naming one grid — into a +/// bucket-start equality from committed block time, making "newest +/// window" trending/leaderboard document and count/sum/avg queries +/// provable. `unique: true` is admitted only for non-overlapping windows /// (`range == step`) sourced from the immutable `$createdAt`. /// /// The first two are orthogonal by construction: the ranked upgrade decides the diff --git a/packages/rs-sdk/tests/fetch/document.rs b/packages/rs-sdk/tests/fetch/document.rs index e4eef2bdb05..fdfe8ee8417 100644 --- a/packages/rs-sdk/tests/fetch/document.rs +++ b/packages/rs-sdk/tests/fetch/document.rs @@ -132,7 +132,7 @@ async fn document_list_drive_query() { start_at: None, start_at_included: true, block_time_ms: None, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let docs = Document::fetch_many(&sdk, query) diff --git a/packages/wasm-drive-verify/src/document/verify_proof.rs b/packages/wasm-drive-verify/src/document/verify_proof.rs index fd7d1b1b1ee..4aae3b52463 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof.rs @@ -108,7 +108,7 @@ pub fn verify_document_proof( start_at: start_at_bytes, start_at_included, block_time_ms, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let (root_hash, documents) = query diff --git a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs index 58f3e7b4099..c04663d37b0 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs @@ -99,7 +99,7 @@ pub fn verify_document_proof_keep_serialized( start_at: start_at_bytes, start_at_included, block_time_ms, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let (root_hash, serialized_docs) = query diff --git a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs index 29a4fb6dcc8..de1ff84becd 100644 --- a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs @@ -107,7 +107,7 @@ pub fn verify_start_at_document_in_proof( start_at: start_at_bytes, start_at_included, block_time_ms, - resolved_time_range_fields: vec![], + resolved_time_ranges: vec![], }; let (root_hash, document_option) = query From 72f70e113f88ceb34c531758584b571152f2bc39 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 20:10:47 +0200 Subject: [PATCH 04/20] refactor(platform-queries): one shared time-range proof normalization; document routable leaderboard shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: COUNT/SUM/AVG/HAVING and the documents FromProof path each carried their own copy of the security-sensitive resolve-from- signed-time + provenance-shape-guard sequence — the exact duplication that once let the aggregate paths omit the resolution. All five now call one normalize_time_range_clauses_with_metadata_time helper (the documents path gains the shape guard it was missing, a tightening). The transform docstring's canonical leaderboard example now spells the routable forms — the grouped count surface needs an In or range clause on the GROUP BY field, so a bare GROUP BY hashtag is not servable. Co-Authored-By: Claude Fable 5 --- .../src/documents/average_proof_helpers.rs | 16 ++------- .../src/documents/count_proof_helpers.rs | 16 ++------- .../src/documents/document_query.rs | 33 ++++++++++++++++--- .../src/documents/having_proof_helpers.rs | 2 +- .../src/documents/sum_proof_helpers.rs | 16 ++------- .../document_type/index/time_range.rs | 16 ++++++--- 6 files changed, 50 insertions(+), 49 deletions(-) diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index 72d526b5e51..53cab1e477b 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -115,24 +115,14 @@ pub(super) fn verify_average_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. + // ...and enforce the same provenance-vs-shape contract the server + // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::resolve_time_range_clauses_with_metadata_time( + super::document_query::normalize_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, )?; - // Same provenance-vs-shape contract the server dispatchers enforce: a - // resolved field may only carry the single equality its resolution - // produced. A response accepting any other shape did not come from an - // honest prover, so reject before mode detection can route on it. - drive::query::validate_resolved_time_range_clause_shapes( - &request.where_clauses, - &resolved_time_ranges, - ) - .map_err(|e| drive_proof_verifier::Error::RequestError { - error: format!("invalid time range query shape: {}", e), - })?; - let document_type = request .data_contract .document_type_for_name(&request.document_type_name) diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index 999f5167c9f..00ebdaa3fa3 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -155,24 +155,14 @@ pub(super) fn verify_count_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. + // ...and enforce the same provenance-vs-shape contract the server + // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::resolve_time_range_clauses_with_metadata_time( + super::document_query::normalize_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, )?; - // Same provenance-vs-shape contract the server dispatchers enforce: a - // resolved field may only carry the single equality its resolution - // produced. A response accepting any other shape did not come from an - // honest prover, so reject before mode detection can route on it. - drive::query::validate_resolved_time_range_clause_shapes( - &request.where_clauses, - &resolved_time_ranges, - ) - .map_err(|e| drive_proof_verifier::Error::RequestError { - error: format!("invalid time range query shape: {}", e), - })?; - let document_type = request .data_contract .document_type_for_name(&request.document_type_name) diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 4eda618351a..19f937aed86 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -524,9 +524,10 @@ impl FromProof for drive_proof_verifier::types::Documents { // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete // bucket using the **quorum-signed** response metadata time — the same // authoritative block time the server used to resolve it — so the - // reconstructed query matches the proof exactly. Resolve before the - // `DriveDocumentQuery` conversion so the engine sees ordinary equality - // clauses. + // reconstructed query matches the proof exactly. Resolve (and run the + // provenance-vs-shape guard, via the one shared normalization helper + // the aggregate verifiers also use) before the `DriveDocumentQuery` + // conversion so the engine sees ordinary equality clauses. let mut resolved_time_ranges = Vec::new(); if !request.time_range_clauses.is_empty() { // The generated `VersionedGrpcResponse::metadata()` handles both @@ -541,7 +542,7 @@ impl FromProof for drive_proof_verifier::types::Documents { .to_string(), })?; resolved_time_ranges = - resolve_time_range_clauses_with_metadata_time(&mut request, time_ms)?; + normalize_time_range_clauses_with_metadata_time(&mut request, time_ms)?; } let mut drive_query: DriveDocumentQuery = @@ -587,6 +588,30 @@ impl FromProof for drive_proof_verifier::types::Documents { /// the pushed clause is an ordinary equality and nothing downstream can /// otherwise tell that it must be matched against the resolved grid's /// bucket starts. +/// [`resolve_time_range_clauses_with_metadata_time`] followed immediately by +/// the provenance-vs-shape guard — the two-step normalization every +/// proof-verification path must run, in this order, before mode detection, +/// covering-index selection, or query reconstruction. One definition so a +/// future verifier path cannot omit either step or run them out of order: +/// the aggregate paths once omitted the resolution entirely (valid proofs +/// were rejected), and a path that resolved without the guard would let a +/// caller-provided `In`/range clause on the resolved field reach the index +/// pickers as if its raw values were bucket starts. +pub(super) fn normalize_time_range_clauses_with_metadata_time( + request: &mut DocumentQuery, + time_ms: u64, +) -> Result, drive_proof_verifier::Error> { + let resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(request, time_ms)?; + drive::query::validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &resolved_time_ranges, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("invalid time range query shape: {}", e), + })?; + Ok(resolved_time_ranges) +} + pub(super) fn resolve_time_range_clauses_with_metadata_time( request: &mut DocumentQuery, time_ms: u64, diff --git a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs index 8d78adc060a..163349156f8 100644 --- a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs @@ -107,7 +107,7 @@ pub(super) fn verify_having_query( // without this, the verifier would accept a query shape the server // refuses. The resolved-field list is discarded: nothing survives the // non-empty-where rejection to consume it. - super::document_query::resolve_time_range_clauses_with_metadata_time( + super::document_query::normalize_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, )?; diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index c05d77ae7a3..75ca94c64cc 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -108,24 +108,14 @@ pub(super) fn verify_sum_query( // block time — BEFORE mode detection and covering-index selection // below, which read `request.where_clauses`; the prover routed on // the resolved shape. + // ...and enforce the same provenance-vs-shape contract the server + // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::resolve_time_range_clauses_with_metadata_time( + super::document_query::normalize_time_range_clauses_with_metadata_time( &mut request, mtd.time_ms, )?; - // Same provenance-vs-shape contract the server dispatchers enforce: a - // resolved field may only carry the single equality its resolution - // produced. A response accepting any other shape did not come from an - // honest prover, so reject before mode detection can route on it. - drive::query::validate_resolved_time_range_clause_shapes( - &request.where_clauses, - &resolved_time_ranges, - ) - .map_err(|e| drive_proof_verifier::Error::RequestError { - error: format!("invalid time range query shape: {}", e), - })?; - let document_type = request .data_contract .document_type_for_name(&request.document_type_name) diff --git a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs index c4f3571332e..3d4663fa662 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs @@ -25,11 +25,17 @@ use serde::{Deserialize, Serialize}; /// /// The canonical use case is "trending" leaderboards: index on /// `(timeRange($createdAt), hashtag)` with `countable`, then query a single -/// bucket — e.g. per-hashtag counts within the bucket (`COUNT(*)` grouped by -/// `hashtag`, with the client ordering the returned groups). Overlapping -/// ranges guarantee that, at any instant, there is always an active range -/// covering a near-full `range` window of history (see -/// [`Self::oldest_active_start`]). +/// bucket. Per-hashtag counts within the bucket are served by the grouped +/// count surface, whose single `GROUP BY` field must itself carry an `In` +/// or range clause: `IN_TIME_RANGE($createdAt, "newest") AND hashtag IN +/// (candidates) GROUP BY hashtag` returns one count per candidate (the +/// client orders the returned groups), and a range predicate on the grouped +/// field with `rangeCountable: true` covers the open-ended form. A bare +/// `GROUP BY hashtag` with no clause on `hashtag` is not routable — +/// aggregate coverage requires every index property to carry an +/// `Equal`/`In`/range clause. Overlapping ranges guarantee that, at any +/// instant, there is always an active range covering a near-full `range` +/// window of history (see [`Self::oldest_active_start`]). /// /// Note that the *server-ordered* form (`ORDER BY COUNT(*)` — the ranked /// query surface) cannot yet be combined with a time-range selection: ranked From 8c59f7c275b51ce454e952aa1f4ae7f0caf099f6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 22:07:20 +0200 Subject: [PATCH 05/20] fix(dpp)!: cap timeRange.phase at one year so no valid timestamp precedes the first bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase < step alone was insufficient: on a huge step (e.g. range = step = 2_000_000_000s) a sub-step phase of 1_900_000_000s puts the grid's first bucket around 2030, so every present-day timestamp fell in the uncovered region — unindexed, and free to bypass a unique time-range constraint until the phase passed. phase must now also be under one year (MAX_TIME_RANGE_PHASE_SECONDS, structural like range % step), so the uncovered region stays inside 1970–1971, unreachable for consensus-validated timestamps, while a year covers every alignment use case. Also from review: index admissibility now binds the provenance field to the transform's source (a fabricated pair — a real transform under a different field — could satisfy the shape guard on the wrong clause while a raw equality rode into the bucketed index), with a mismatch regression; the WASM query surface gains the optional grid ({ range, step, phase? }) so JS callers can address multi-grid fields at all; and the estimated-cost KeySize fan-out gets direct tests (exact bounded count, distinct unique_ids so grovedb cannot collapse them, untouched max_size, clamp above the versioned cap). Co-Authored-By: Claude Fable 5 --- .../document/v3/document-meta.json | 2 +- .../data_contract/document_type/index/mod.rs | 85 +++++++++++++++++++ .../document_type/index/time_range.rs | 10 ++- .../drive/document/index_level_tree_types.rs | 64 +++++++++++++- .../query/drive_document_count_query/tests.rs | 28 ++++++ packages/rs-drive/src/query/mod.rs | 17 +++- packages/wasm-sdk/src/queries/document.rs | 72 ++++++++++++++-- 7 files changed, 259 insertions(+), 19 deletions(-) diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index de02a70dbdb..bfc75c3058d 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -616,7 +616,7 @@ "phase": { "type": "integer", "minimum": 0, - "description": "Grid alignment phase, in seconds. Range starts are `phase + k * step`; must be strictly less than `step` (a larger value would be a redundant spelling of `phase % step`). A pure alignment offset — it moves where window boundaries fall (e.g. daily windows cut at 06:00 UTC instead of midnight) and never excludes any timestamp. Defaults to 0." + "description": "Grid alignment phase, in seconds. Range starts are `phase + k * step`; must be strictly less than `step` (a larger value would be a redundant spelling of `phase % step`) and strictly less than one year (31536000 — a phase further out could sit past current block time on a huge step, leaving valid timestamps before the grid's first bucket). A pure alignment offset — it moves where window boundaries fall (e.g. daily windows cut at 06:00 UTC instead of midnight) and never excludes any real timestamp. Defaults to 0." } }, "required": ["on", "range", "step"], diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 97502136c56..f99e640317e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -56,6 +56,19 @@ pub const RANKED_AVERAGEABLE: &str = "rankedAverageable"; /// every range containing its timestamp. See [`TimeRangeTransform`]. /// Meta-schema v3+ (protocol version 14). pub const TIME_RANGE: &str = "timeRange"; +/// Upper bound (exclusive) on `timeRange.phase`, in seconds: one 365-day +/// year. The phase aligns window boundaries within one step, and combined +/// with `phase < step` this cap is what makes the uncovered region before +/// the grid's first bucket (`[0, phase)` on the millisecond timeline) +/// unreachable: it stays strictly inside 1970–1971, decades before any +/// Platform block time, so a required system timestamp — which consensus +/// validates against block time — can never fall outside every window. +/// Without the cap, a sub-step phase on a huge step could sit years in the +/// future, silently unindexing valid documents and bypassing unique +/// time-range constraints until it passed. A structural rule (like +/// `range % step == 0`), not a versioned limit: it is part of what makes a +/// transform well-formed at all. +pub const MAX_TIME_RANGE_PHASE_SECONDS: u64 = 31_536_000; #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)] @@ -1496,6 +1509,28 @@ impl Index { transform.phase_seconds, transform.step_seconds ))); } + // `phase < step` alone is not enough: the region `[0, phase)` is + // outside every window (bucket starts are `phase + k*step`, + // k >= 0), and with a huge step the phase — while still a valid + // sub-step alignment — could land years in the future (e.g. + // `step = 2_000_000_000s`, `phase = 1_900_000_000s` ≈ 2030), + // leaving every present-day timestamp without index entries and + // silently bypassing unique constraints until the phase passes. + // Capping the phase at one year keeps the uncovered region + // strictly inside 1970–1971 — decades before any Platform block + // time, and `$createdAt` & co are consensus-validated against + // block time — so no valid document timestamp can ever precede + // the first bucket. One year covers every alignment use case + // (time-of-day, weekday, month and year boundaries, timezones). + if transform.phase_seconds >= MAX_TIME_RANGE_PHASE_SECONDS { + return Err(DataContractError::InvalidContractStructure(format!( + "timeRange.phase ({} seconds) must be less than one year ({} seconds): \ + the phase aligns window boundaries, and a larger value would leave \ + valid document timestamps before the grid's first bucket, outside \ + every window", + transform.phase_seconds, MAX_TIME_RANGE_PHASE_SECONDS + ))); + } if transform.range_seconds == 0 { return Err(DataContractError::InvalidContractStructure( "timeRange.range must be greater than zero".to_string(), @@ -1743,6 +1778,56 @@ mod tests { )); } + /// `phase < step` alone is not enough: on a huge step a sub-step phase + /// can sit years in the *future*, leaving every present-day timestamp + /// before the grid's first bucket — unindexed, and free to bypass a + /// unique constraint. The one-year cap closes that: the uncovered + /// region stays inside 1970–1971, unreachable for consensus-validated + /// timestamps. + #[test] + fn time_range_phase_is_capped_at_one_year_even_when_the_step_allows_more() { + // The reported exploit shape: overlap factor 1, everything scalable, + // phase < step — but the phase anchor lands around 2030, so a unique + // index would silently skip every document until then. + let map = index_value_map_with_extra_time_range_key( + 2_000_000_000, + 2_000_000_000, + "phase", + 1_900_000_000, + ); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + + // Just under the cap on the same huge step parses fine. + let map = index_value_map_with_extra_time_range_key( + 2_000_000_000, + 2_000_000_000, + "phase", + MAX_TIME_RANGE_PHASE_SECONDS - 1, + ); + let index = Index::try_from_value_map(map.as_slice(), false, true).expect("should parse"); + assert_eq!( + index.time_range.expect("transform set").phase_seconds, + MAX_TIME_RANGE_PHASE_SECONDS - 1 + ); + + // Exactly the cap is rejected (exclusive bound). + let map = index_value_map_with_extra_time_range_key( + 2_000_000_000, + 2_000_000_000, + "phase", + MAX_TIME_RANGE_PHASE_SECONDS, + ); + let err = Index::try_from_value_map(map.as_slice(), false, true).unwrap_err(); + assert!(matches!( + err, + DataContractError::InvalidContractStructure(_) + )); + } + /// The pre-phase grammar's `origin` key no longer exists: it named an /// absolute grid anchor with beginning-of-time semantics the phase-only /// design dropped, so it must be rejected as an unknown field rather diff --git a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs index 3d4663fa662..a62551a5efc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs @@ -79,10 +79,12 @@ pub struct TimeRangeTransform { pub step_seconds: u64, /// Grid alignment phase, in seconds. Range starts are the millisecond /// timestamps `phase_ms() + k * step_ms()` for `k = 0, 1, 2, …`. A pure - /// phase offset: contract validation requires `phase < step`, so shifting - /// the grid never excludes any real timestamp — it only moves where the - /// window boundaries fall (e.g. daily windows cut at 06:00 UTC instead of - /// midnight). Defaults to `0`. + /// phase offset: contract validation requires `phase < step` AND + /// `phase < MAX_TIME_RANGE_PHASE_SECONDS` (one year), so shifting the + /// grid never excludes any real timestamp — the region before the first + /// bucket stays inside 1970–1971, unreachable for consensus-validated + /// timestamps — it only moves where the window boundaries fall (e.g. + /// daily windows cut at 06:00 UTC instead of midnight). Defaults to `0`. #[cfg_attr(feature = "serde-conversion", serde(rename = "phase", default))] pub phase_seconds: u64, } diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index 028dd2d78d6..025da4aec2f 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -136,7 +136,7 @@ pub(crate) fn index_level_tree_types_with_continuation_demotion( /// /// Shared by the insert and delete v2 walkers (same must-not-drift contract /// as the tree-type derivation above); the entry-key rule itself — null keeps -/// its single null entry, pre-origin timestamps produce no entries, +/// its single null entry, epoch-sliver timestamps produce no entries, /// undecodable values keep their raw key — lives in /// [`TimeRangeTransform::entry_keys_for_raw`], which the update walker also /// calls. @@ -459,4 +459,66 @@ mod tests { ) .expect("the continuation must be insertable under the demoted value tree"); } + /// The estimated-cost (`KeySize`) branch of [`time_range_index_keys`] is + /// consensus-sensitive fee math: it must emit exactly the bounded + /// overlap count, keep every synthetic key's `max_size` untouched, and + /// make each `unique_id` distinct — grovedb's batch structure collapses + /// identical `(path, key)` operations, so `overlap` copies of one key + /// would silently estimate a single bucket's cost. + #[test] + fn estimated_time_range_fan_out_emits_distinct_worst_case_keys() { + use super::time_range_index_keys; + use crate::util::object_size_info::DriveKeyInfo; + use dpp::data_contract::document_type::TimeRangeTransform; + use grovedb::batch::key_info::KeyInfo; + + // 6h window sliding every 2h — overlap factor 3. + let transform = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 21_600, + step_seconds: 7_200, + phase_seconds: 0, + }; + let key = DriveKeyInfo::KeySize(KeyInfo::MaxKeySize { + unique_id: vec![7u8; 4], + max_size: 8, + }); + + let keys = time_range_index_keys(Some(&transform), key.clone(), 24); + assert_eq!(keys.len(), 3, "one worst-case key per overlapping bucket"); + let mut unique_ids = Vec::new(); + for entry in &keys { + let DriveKeyInfo::KeySize(KeyInfo::MaxKeySize { + unique_id, + max_size, + }) = entry + else { + panic!("the KeySize branch must stay on the estimation path"); + }; + assert_eq!( + *max_size, 8, + "the ordinal suffix disambiguates unique_id only; the estimated \ + key size must be the timestamp key's" + ); + unique_ids.push(unique_id.clone()); + } + let distinct: std::collections::BTreeSet<_> = unique_ids.iter().collect(); + assert_eq!( + distinct.len(), + 3, + "identical unique_ids would collapse in the batch and under-estimate" + ); + + // An unvalidated transform above the version's cap is clamped: the + // estimation work stays bounded by what the protocol version allows. + let oversized = TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 100 * 3_600, + step_seconds: 3_600, + phase_seconds: 0, + }; + assert_eq!(oversized.overlap_factor(), 100); + let keys = time_range_index_keys(Some(&oversized), key, 24); + assert_eq!(keys.len(), 24, "fan-out must clamp to the versioned cap"); + } } diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index 568efe640b6..845d1f21fed 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -3596,4 +3596,32 @@ mod time_range_picker_tests { .is_none() ); } + + /// Provenance is a `(field, transform)` pair and admissibility must bind + /// them together: a fabricated entry carrying the real transform under a + /// *different* field would let the shape guard validate the wrong clause + /// while a caller-supplied raw equality on the transform's source rode + /// into the bucketed index as if it were a resolved bucket start. The + /// resolver always produces `field == transform.source`; anything else + /// must be inadmissible. + #[test] + fn provenance_with_a_field_not_matching_its_transform_source_admits_nothing() { + let indexes = indexes(); + let where_clauses = vec![ + equal(SOURCE, Value::U64(6 * HOUR_MS)), + equal("hashtag", Value::Text("ibiza".to_string())), + ]; + let mut mismatched = source_resolution(); + mismatched[0].field = "hashtag".to_string(); + assert!( + DriveDocumentCountQuery::find_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &mismatched, + ) + .is_none(), + "a transform attached to a field other than its source must not \ + admit the bucketed index (nor, being a resolution, the plain one)" + ); + } } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index bb180af8224..673c9ce0331 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -790,10 +790,19 @@ pub fn index_admissible_for_resolved_time_range( ) -> bool { match resolved_time_ranges { [] => index.time_range.is_none(), - [resolved] => index - .time_range - .as_ref() - .is_some_and(|transform| *transform == resolved.transform), + // Both halves of the provenance must agree with the candidate: the + // transform (which grid the bucket start was computed from) AND the + // field (which clause the shape guard validated). The two are + // independently settable by a direct Rust caller, and a mismatched + // pair — a real transform attached to some other field — would let + // the shape guard validate the wrong clause while a caller-supplied + // raw equality on the transform's source rode into the bucketed + // index as if it were a resolved bucket start. The resolver always + // produces `field == transform.source`; this makes fabricated + // provenance that doesn't inadmissible everywhere. + [resolved] => index.time_range.as_ref().is_some_and(|transform| { + transform.source == resolved.field && *transform == resolved.transform + }), _ => false, } } diff --git a/packages/wasm-sdk/src/queries/document.rs b/packages/wasm-sdk/src/queries/document.rs index fb1d67f0a6f..731ff41ff2f 100644 --- a/packages/wasm-sdk/src/queries/document.rs +++ b/packages/wasm-sdk/src/queries/document.rs @@ -11,7 +11,7 @@ use dash_sdk::platform::documents::document_history_query::DocumentHistoryQuery; use dash_sdk::platform::documents::document_query::DocumentQuery; use dash_sdk::platform::Fetch; use dash_sdk::platform::FetchMany; -use drive::query::{OrderClause, TimeRangeSelector, WhereClause, WhereOperator}; +use drive::query::{OrderClause, TimeRangeGridSpec, TimeRangeSelector, WhereClause, WhereOperator}; use drive_proof_verifier::types::DocumentHistory; use drive_proof_verifier::{DocumentSplitAverages, DocumentSplitCounts, DocumentSplitSums}; use js_sys::{BigInt, Map}; @@ -130,9 +130,18 @@ export interface DocumentsQuery { * - `selector: "oldest"` → the oldest still-active range (a near-full * trailing window of ~`range`; best for "trending over the last window"). * - `selector: "newest"` → the freshest started range (latest partial slice). + * + * `grid` names one of the field's grids in the contract's own declared + * seconds (`{ range, step, phase? }`) — required when the contract buckets + * the field with more than one `timeRange` grid, where the bare selector + * is ambiguous and rejected. A zero phase is spelled by omission. * @default [] */ - timeRange?: { field: string; selector: "newest" | "oldest" }[]; + timeRange?: { + field: string; + selector: "newest" | "oldest"; + grid?: { range: number; step: number; phase?: number }; + }[]; } /** @@ -298,8 +307,11 @@ async fn build_documents_query( if let Some(time_range_values) = time_range { for clause_json in time_range_values.iter() { - let (field, selector) = parse_time_range_clause(clause_json)?; - query = query.with_time_range(field, selector); + let (field, selector, grid) = parse_time_range_clause(clause_json)?; + query = match grid { + Some(grid) => query.with_time_range_grid(field, selector, grid), + None => query.with_time_range(field, selector), + }; } } @@ -482,13 +494,21 @@ fn parse_where_clause(json_clause: &JsonValue) -> Result Result<(String, TimeRangeSelector), WasmSdkError> { +) -> Result<(String, TimeRangeSelector, Option), WasmSdkError> { let object = json_clause.as_object().ok_or_else(|| { - WasmSdkError::invalid_argument("timeRange clause must be an object { field, selector }") + WasmSdkError::invalid_argument( + "timeRange clause must be an object { field, selector, grid? }", + ) })?; let field = object .get("field") @@ -506,7 +526,41 @@ fn parse_time_range_clause( "timeRange clause `selector` must be \"newest\" or \"oldest\"", ) })?; - Ok((field, selector)) + let grid = match object.get("grid") { + None | Some(JsonValue::Null) => None, + Some(grid_json) => { + let grid_object = grid_json.as_object().ok_or_else(|| { + WasmSdkError::invalid_argument( + "timeRange clause `grid` must be an object { range, step, phase? } in \ + the contract's declared seconds", + ) + })?; + let grid_number = |key: &str| -> Result { + grid_object + .get(key) + .and_then(JsonValue::as_u64) + .ok_or_else(|| { + WasmSdkError::invalid_argument(format!( + "timeRange clause `grid.{}` must be an unsigned integer of seconds, \ + exactly as the contract declares it", + key + )) + }) + }; + let range_seconds = grid_number("range")?; + let step_seconds = grid_number("step")?; + let phase_seconds = match grid_object.get("phase") { + None | Some(JsonValue::Null) => 0, + Some(_) => grid_number("phase")?, + }; + Some(TimeRangeGridSpec { + range_seconds, + step_seconds, + phase_seconds, + }) + } + }; + Ok((field, selector, grid)) } /// Parse JSON order by clause into OrderClause From 6e99f918650fbef5c45c7697b0f7f112ab5ec9b3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 23:11:59 +0200 Subject: [PATCH 06/20] fix(drive)!: refuse time-range selections on the ranked and HAVING surfaces end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaces pin plain ranked indexes with equality prefixes, and both pickers exclude transformed indexes — so a resolved bucket-start equality reaching them selects a plain index over raw timestamps, and a proof over it authenticates boundary-timestamp matches instead of window membership. The server's ranked route already refused the provenance; the HAVING route dropped it on the dispatch floor, and both SDK verifiers resolved the selector and then discarded the provenance, accepting request shapes every honest server rejects — a malicious node's validly-proven wrong answer. DocumentHavingRequest now carries resolved_time_ranges and drive rejects it non-empty (same drive-owns-the-rejection pattern as the ranked request); the routing layer threads it through. The ranked and HAVING verifiers normalize through the shared helper and refuse non-empty provenance with the matching message. Also from review: the Drive-to-DocumentQuery conversions (both From impls and new_with_drive_query) are now fallible and refuse a query carrying resolution provenance — the resolved bucket equality would silently demote to a raw-timestamp predicate, and the original selector is not reconstructible. Tests: the HAVING route refuses IN_TIME_RANGE at the handler; both verifier entry points refuse before authenticating any proof; the conversion refuses a provenance-carrying drive query. Co-Authored-By: Claude Fable 5 --- .../src/documents/document_query.rs | 77 +++++----- .../src/documents/having_proof_helpers.rs | 36 +++-- .../src/documents/ranked_proof_helpers.rs | 41 ++++-- .../document_query/v1/dispatch/having.rs | 3 + .../src/query/document_query/v1/mod.rs | 1 + .../src/query/document_query/v1/tests.rs | 137 ++++++++++++++++++ .../drive_dispatcher.rs | 27 +++- .../drive_document_having_query/tests.rs | 4 + packages/rs-sdk/src/platform/query.rs | 7 +- 9 files changed, 267 insertions(+), 66 deletions(-) diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 19f937aed86..3e32e37fe81 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -213,8 +213,14 @@ impl DocumentQuery { } /// Create new document query based on a [DriveDocumentQuery]. - pub fn new_with_drive_query(d: &DriveDocumentQuery) -> Self { - Self::from(d) + /// + /// Fails when the drive query carries time-range resolution provenance + /// (`resolved_time_ranges`): the resolved bucket equality cannot be + /// represented without it — see the `TryFrom` impl. Build the query + /// with [`Self::with_time_range`] / [`Self::with_time_range_grid`] + /// instead for time-range selections. + pub fn new_with_drive_query(d: &DriveDocumentQuery) -> Result { + Self::try_from(d) } /// Point to a specific document ID. @@ -950,8 +956,28 @@ fn encode_v0( }) } -impl<'a> From<&'a DriveDocumentQuery<'a>> for DocumentQuery { - fn from(value: &'a DriveDocumentQuery<'a>) -> Self { +impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { + type Error = crate::error::Error; + + /// Fallible by necessity: a drive query carrying `resolved_time_ranges` + /// holds bucket-start equalities whose meaning lives in the provenance, + /// and `DocumentQuery` has no field to carry it — the original + /// `IN_TIME_RANGE` selector cannot be reconstructed from the resolved + /// query. Serializing such a query would silently demote the bucket + /// equality to a raw-timestamp predicate: a transformed-index-only + /// contract then rejects the request, while a contract with a competing + /// plain index returns a different — but validly proven — result. + fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result { + if !value.resolved_time_ranges.is_empty() { + return Err(crate::error::Error::Config( + "a drive query carrying time-range resolution provenance cannot be \ + converted to a DocumentQuery: the resolved bucket equality would be \ + demoted to a raw-timestamp predicate. Build the DocumentQuery with \ + `with_time_range` / `with_time_range_grid` instead, so the selector \ + is resolved against the signed response metadata" + .to_string(), + )); + } let data_contract = value.contract.clone(); let document_type_name = value.document_type.name(); let where_clauses = value.internal_clauses.clone().into(); @@ -968,7 +994,7 @@ impl<'a> From<&'a DriveDocumentQuery<'a>> for DocumentQuery { None }; - Self { + Ok(Self { // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range // concept — it's a documents-only query. Default to the // v1 documents shape. @@ -983,44 +1009,17 @@ impl<'a> From<&'a DriveDocumentQuery<'a>> for DocumentQuery { limit, offset, start, - } + }) } } -impl<'a> From> for DocumentQuery { - fn from(value: DriveDocumentQuery<'a>) -> Self { - let data_contract = value.contract.clone(); - let document_type_name = value.document_type.name(); - let where_clauses = value.internal_clauses.clone().into(); - let order_by_clauses = value.order_by.iter().map(|(_, v)| v.clone()).collect(); - let limit = value.limit.unwrap_or(0) as u32; - let offset = value.offset.map(u32::from); - - let start = if let Some(start_at) = value.start_at { - match value.start_at_included { - true => Some(Start::StartAt(start_at.to_vec())), - false => Some(Start::StartAfter(start_at.to_vec())), - } - } else { - None - }; +impl<'a> TryFrom> for DocumentQuery { + type Error = crate::error::Error; - Self { - // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range - // concept — it's a documents-only query. Default to the - // v1 documents shape. - select: SelectProjection::documents(), - data_contract: Arc::new(data_contract), - document_type_name: document_type_name.to_string(), - where_clauses, - time_range_clauses: Vec::new(), - group_by: Vec::new(), - having: Vec::new(), - order_by_clauses, - limit, - offset, - start, - } + /// By-value twin of the by-reference conversion above — same + /// provenance rejection, same rationale. + fn try_from(value: DriveDocumentQuery<'a>) -> Result { + DocumentQuery::try_from(&value) } } diff --git a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs index 163349156f8..ea0056efc02 100644 --- a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs @@ -100,17 +100,31 @@ pub(super) fn verify_having_query( .metadata() .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; - // Resolve any pending time-range selection into a where clause before - // the shape check, exactly as the server does before routing: a - // having-range query must have no where clauses, so a resolved - // selection is rejected here the same way the server rejects it — - // without this, the verifier would accept a query shape the server - // refuses. The resolved-field list is discarded: nothing survives the - // non-empty-where rejection to consume it. - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + // Resolve any pending time-range selection through the shared + // normalization helper, then reject the request outright if anything + // resolved — mirroring the server-side rejection in drive's + // `execute_document_having_request`. HAVING accepts equality prefixes + // on compound ranked indexes (which exclude transformed indexes), so a + // resolved bucket-start equality would pin a *plain* ranked index on + // the same timestamp field, and a malicious node could return a valid + // proof over raw-timestamp matches at the bucket boundary instead of + // the requested window. Without this guard the verifier would + // authenticate a request shape honest servers refuse. + let resolved_time_ranges = + super::document_query::normalize_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + if !resolved_time_ranges.is_empty() { + return Err(drive_proof_verifier::Error::RequestError { + error: "a HAVING query cannot carry a time-range (IN_TIME_RANGE) selection: its \ + equality prefixes pin plain ranked indexes, so a resolved bucket-start \ + equality would authenticate raw-timestamp matches at the bucket boundary \ + instead of window membership — the server rejects this request and the \ + verifier must not authenticate a proof for it" + .to_string(), + }); + } let document_type = request .data_contract diff --git a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index 1ed5f58a6ee..5621531d2c5 100644 --- a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -128,20 +128,33 @@ pub(super) fn verify_ranked_query( .metadata() .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; - // Resolve any pending time-range (`IN_TIME_RANGE`) selections into - // concrete bucket-equality clauses before the shape check reads - // `request.where_clauses` — the same invariant the count/sum/average - // helpers follow. The ranked surface rejects where clauses today, so a - // ranked + time-range query fails the shape check below with the same - // "no where clauses" error the server's router produces (rather than - // passing the local pre-flight and dying server-side); if ranked routing - // ever grows an equality prefix, resolution is already in place. - // The resolved-field list is discarded: the ranked picker excludes - // bucketed indexes outright, so there is nothing for it to pin. - super::document_query::resolve_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + // Resolve any pending time-range (`IN_TIME_RANGE`) selections through + // the shared normalization helper, then reject the request outright if + // anything resolved — mirroring the server, whose + // `execute_document_ranked_request` refuses non-empty provenance. The + // rejection is load-bearing, not a shape formality: ranked mode accepts + // equality pins on compound ranked indexes, and the ranked picker + // excludes transformed indexes, so a resolved bucket-start equality + // would pin a *plain* ranked index on the same timestamp field. A + // malicious node could then return a valid proof over that subtree — + // documents whose raw timestamp equals the bucket boundary, not the + // requested window — and without this guard the verifier would + // authenticate it even though an honest server rejects the request. + let resolved_time_ranges = + super::document_query::normalize_time_range_clauses_with_metadata_time( + &mut request, + mtd.time_ms, + )?; + if !resolved_time_ranges.is_empty() { + return Err(drive_proof_verifier::Error::RequestError { + error: "a ranked query cannot carry a time-range (IN_TIME_RANGE) selection: \ + ranking groups by an index's own property, a document belongs to every \ + bucket containing its timestamp, and the resolved bucket-start equality \ + could only pin a plain index over raw timestamps — the server rejects \ + this request and the verifier must not authenticate a proof for it" + .to_string(), + }); + } let document_type = request .data_contract diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs index eb561a62775..f76c42195c4 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs @@ -21,6 +21,7 @@ use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; +use drive::query::ResolvedTimeRange; use drive::query::{ DocumentHavingRequest, DocumentHavingResponse, HavingClause, OrderClause, SelectProjection, WhereClause, @@ -51,6 +52,7 @@ impl Platform { group_by: Vec, having: Vec, where_clauses: Vec, + resolved_time_ranges: Vec, order_clauses: Vec, limit: Option, offset: Option, @@ -94,6 +96,7 @@ impl Platform { having: &having, order_by: &order_clauses, where_clauses: &where_clauses, + resolved_time_ranges: &resolved_time_ranges, limit, offset, has_start_at: start.is_some(), diff --git a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs index e3088cdb45d..0f46507927e 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs @@ -407,6 +407,7 @@ impl Platform { group_by, having_clauses, where_clauses, + resolved_time_ranges, order_by_clauses, limit, offset, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index e47ac126c5c..68603d24d6c 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -4041,6 +4041,7 @@ mod having_trust_boundary { having: &having, order_by: &[], where_clauses: &[], + resolved_time_ranges: &[], limit: Some(10), offset: None, has_start_at: false, @@ -5373,4 +5374,140 @@ mod time_range_proof_verification { ); } } + // ----- routes that must refuse time-range selections ------------------ + + use drive_proof_verifier::{DocumentHavingEntries, DocumentRankedEntries}; + + /// The HAVING route accepts equality prefixes that pin plain ranked + /// indexes, and its picker excludes transformed ones — so a resolved + /// bucket-start equality reaching it would be served from raw + /// timestamps at the bucket boundary instead of the selected window. + /// The server must refuse the combination outright (drive owns the + /// rejection, through `DocumentHavingRequest::resolved_time_ranges`). + #[test] + fn the_having_route_refuses_a_time_range_selection() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + + let request = GetDocumentsRequestV1 { + group_by: vec!["hashtag".to_string()], + having: vec![hc( + having_aggregate::Function::Count, + "", + having_clause::Operator::GreaterThan, + Value::U64(0), + )], + ..trending_request(contract.id().to_vec(), "ibiza", select_count_star()) + }; + let result = platform + .query_documents_v1(request, &state, version) + .expect("transport-level success"); + assert!( + format!("{:?}", result.errors).contains("time-range"), + "the HAVING route must refuse a time-range selection, got {:?}", + result.errors + ); + } + + /// The ranked and HAVING *verifiers* must refuse a time-range request + /// before authenticating anything: both surfaces pin plain ranked + /// indexes with equality prefixes, so a malicious node could otherwise + /// prove raw-timestamp matches at the bucket boundary against a request + /// shape every honest server rejects. The rejection fires ahead of + /// proof verification, so any signed response works as the fixture. + #[test] + fn ranked_and_having_verifiers_refuse_time_range_requests() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + let request = trending_request(contract.id().to_vec(), "ibiza", select_count_star()); + let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); + + let time_range_query = || { + SdkDocumentQuery::new(Arc::new(contract.clone()), DOCUMENT_TYPE) + .expect("the fixture has this document type") + .with_select(SelectProjection::count_star()) + .with_time_range(CREATED_AT, TimeRangeSelector::Newest) + }; + + let error = + >::maybe_from_proof_with_metadata( + time_range_query(), + signed_response(proof.clone(), &mtd), + Network::Testnet, + version, + &provider, + ) + .expect_err("the ranked verifier must refuse a time-range request"); + assert!( + error.to_string().contains("time-range"), + "expected the ranked time-range refusal, got {error}" + ); + + let error = + >::maybe_from_proof_with_metadata( + time_range_query().with_having(vec![drive::query::HavingClause { + aggregate: drive::query::HavingAggregate { + function: drive::query::HavingAggregateFunction::Count, + field: String::new(), + }, + operator: drive::query::HavingOperator::GreaterThan, + right: drive::query::HavingRightOperand::Value(Value::U64(0)), + }]), + signed_response(proof, &mtd), + Network::Testnet, + version, + &provider, + ) + .expect_err("the HAVING verifier must refuse a time-range request"); + assert!( + error.to_string().contains("time-range"), + "expected the HAVING time-range refusal, got {error}" + ); + } + + /// A drive query carrying resolution provenance has no faithful + /// `DocumentQuery` form — serializing it would demote the resolved + /// bucket equality to a raw-timestamp predicate — so the conversion + /// must refuse rather than silently rewrite the question. + #[test] + fn a_resolved_drive_query_cannot_convert_to_a_document_query() { + let (platform, base_state, version) = setup_platform(None, Network::Testnet, None); + let (contract, _documents, state) = setup_trending(&platform, &base_state, version); + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("post doctype exists"); + let block_time_ms = state + .last_committed_block_time_ms() + .expect("the fixture committed a block"); + let (clause, resolution) = drive::query::resolve_time_range_bucket_clause( + CREATED_AT, + TimeRangeSelector::Newest, + None, + document_type, + block_time_ms, + ) + .expect("the committed block time is inside an active range"); + + let mut drive_query = DriveDocumentQuery::from_typed_clauses( + vec![clause], + Vec::new(), + None, + None, + true, + None, + &contract, + document_type, + &platform.config.drive, + version, + ) + .expect("the resolved clause builds a drive query"); + drive_query.resolved_time_ranges = vec![resolution]; + + let error = SdkDocumentQuery::try_from(&drive_query) + .expect_err("resolution provenance must not survive the conversion"); + assert!( + error.to_string().contains("provenance"), + "expected the provenance rejection, got {error}" + ); + } } diff --git a/packages/rs-drive/src/query/drive_document_having_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_having_query/drive_dispatcher.rs index b484460081b..77887a6b729 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/drive_dispatcher.rs @@ -5,10 +5,11 @@ use super::super::drive_document_ranked_query::{RankedEntry, RankedPaginationInputs}; use super::mode_detection::detect_having_mode; use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::having::HavingClause; use crate::query::projection::SelectProjection; -use crate::query::{OrderClause, WhereClause}; +use crate::query::{OrderClause, ResolvedTimeRange, WhereClause}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -47,6 +48,16 @@ pub struct DocumentHavingRequest<'a> { /// equality pins on the covering compound index's leading /// properties for the pinned-prefix form. pub where_clauses: &'a [WhereClause], + /// The fields among `where_clauses` whose equality clause was produced by + /// `IN_TIME_RANGE` resolution (see + /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]). + /// Must be empty: HAVING's equality prefixes pin plain ranked indexes + /// (its picker excludes transformed ones), so a resolved bucket-start + /// equality would authenticate raw-timestamp matches at the bucket + /// boundary instead of window membership. Carried (and rejected) here + /// for the same reason the ranked request carries it: drive owns the + /// rejection regardless of which upstream path built the request. + pub resolved_time_ranges: &'a [ResolvedTimeRange], /// Request `limit`. **Required**; `1 ..= MAX_HAVING_LIMIT`. pub limit: Option, /// Request `offset`. Must be `None` — the range walk has no skip. @@ -104,6 +115,20 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // Before mode detection reads `where_clauses`: a resolved bucket + // equality is indistinguishable from a hand-written equality pin, and + // the pinned-prefix form would accept it against a plain ranked index + // over raw timestamps — a validly-proven answer to a different + // question. See `DocumentHavingRequest::resolved_time_ranges`. + if !request.resolved_time_ranges.is_empty() { + return Err(Error::Query(QuerySyntaxError::Unsupported( + "a HAVING query cannot carry a time-range (IN_TIME_RANGE) selection: its \ + equality prefixes pin plain ranked indexes, so a resolved bucket-start \ + equality would match raw timestamps at the bucket boundary instead of the \ + selected window" + .to_string(), + ))); + } let mode = detect_having_mode( &request.select, request.group_by, diff --git a/packages/rs-drive/src/query/drive_document_having_query/tests.rs b/packages/rs-drive/src/query/drive_document_having_query/tests.rs index 9bfd3277323..496a199d79a 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/tests.rs @@ -759,6 +759,7 @@ mod execution { having: &having, order_by: &order_by, where_clauses: &[], + resolved_time_ranges: &[], limit: case.limit, offset: None, has_start_at: false, @@ -1379,6 +1380,7 @@ mod identifier_group_keys { having: &having, order_by, where_clauses: &[], + resolved_time_ranges: &[], limit: Some(10), offset: None, has_start_at: false, @@ -1672,6 +1674,7 @@ mod pinned_prefix { having: &having, order_by, where_clauses, + resolved_time_ranges: &[], limit: Some(10), offset: None, has_start_at: false, @@ -2030,6 +2033,7 @@ mod pinned_prefix { having: &having, order_by: &[], where_clauses: &null_pin, + resolved_time_ranges: &[], limit: Some(10), offset: None, has_start_at: false, diff --git a/packages/rs-sdk/src/platform/query.rs b/packages/rs-sdk/src/platform/query.rs index 52d7119cf54..a9acc2639ed 100644 --- a/packages/rs-sdk/src/platform/query.rs +++ b/packages/rs-sdk/src/platform/query.rs @@ -492,7 +492,12 @@ impl Query for DriveDocumentQuery<'_> { .to_string(), )); } - let q: DocumentQuery = self.into(); + // Fallible: a drive query carrying time-range resolution provenance + // has no faithful `DocumentQuery` form (the resolved bucket equality + // would demote to a raw-timestamp predicate) and is refused — build + // a `DocumentQuery` with `with_time_range` / `with_time_range_grid` + // for time-range selections instead. + let q: DocumentQuery = self.try_into()?; Ok(q) } } From a6d6aa025c2dbaa0cec1e8b80fff1a61b970f436 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 23:56:12 +0200 Subject: [PATCH 07/20] docs(dapi-grpc): tighten the IN_TIME_RANGE operator comment Co-Authored-By: Claude Fable 5 --- .../platform/v0/objective-c/Platform.pbobjc.h | 26 ++++++++----------- .../protos/platform/v0/platform.proto | 26 ++++++++----------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h index 7ac2d869d93..a713016035f 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h @@ -387,21 +387,17 @@ typedef GPB_ENUM(GetDocumentsRequest_WhereOperator) { GetDocumentsRequest_WhereOperator_StartsWith = 10, /** - * Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the - * v0 CBOR where surface is unaffected). The clause's `field` names a - * timestamp property covered by a `timeRange` index. The operand is - * either `DocumentFieldValue.text` — the bare selector `"newest"` or - * `"oldest"`, legal while exactly one grid buckets the field — or - * `DocumentFieldValue.list` of `[text(selector), uint64(range), - * uint64(step)]` / `[…, uint64(phase)]`, naming one of the field's - * grids in the contract's own declared seconds (required when several - * grids bucket the field; a zero phase is spelled by omission, so - * every grid has exactly one wire spelling). The server resolves the - * selector to a concrete equality on the named grid's bucket start - * using the current block time, and the verifier re-derives the same - * bucket from the quorum-signed response metadata time — so the proof - * is an ordinary index/count proof. See `timeRange` in the document - * meta-schema and `drive::query::resolve_time_range_bucket_clause`. + * Time-range bucket selection (v1 only; the v0 CBOR surface is + * unaffected). `field` names a timestamp covered by a `timeRange` + * index. Operand: `text` selector `"newest"`/`"oldest"` when one grid + * buckets the field, or `list` `[selector, range, step(, phase)]` in + * the contract's declared seconds to name one of several grids (zero + * phase is spelled by omission — one wire spelling per grid). The + * server resolves it to a bucket-start equality from current block + * time; the verifier re-derives the same bucket from the quorum-signed + * metadata time — an ordinary index/count proof. See `timeRange` in + * the document meta-schema and + * `drive::query::resolve_time_range_bucket_clause`. **/ GetDocumentsRequest_WhereOperator_InTimeRange = 11, }; diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index cb361c01c14..adb8f565c9d 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -594,21 +594,17 @@ message GetDocumentsRequest { BETWEEN_EXCLUDE_RIGHT = 8; IN = 9; STARTS_WITH = 10; - // Time-range bucket selection (v1 / `GetDocumentsRequestV1` only — the - // v0 CBOR where surface is unaffected). The clause's `field` names a - // timestamp property covered by a `timeRange` index. The operand is - // either `DocumentFieldValue.text` — the bare selector `"newest"` or - // `"oldest"`, legal while exactly one grid buckets the field — or - // `DocumentFieldValue.list` of `[text(selector), uint64(range), - // uint64(step)]` / `[…, uint64(phase)]`, naming one of the field's - // grids in the contract's own declared seconds (required when several - // grids bucket the field; a zero phase is spelled by omission, so - // every grid has exactly one wire spelling). The server resolves the - // selector to a concrete equality on the named grid's bucket start - // using the current block time, and the verifier re-derives the same - // bucket from the quorum-signed response metadata time — so the proof - // is an ordinary index/count proof. See `timeRange` in the document - // meta-schema and `drive::query::resolve_time_range_bucket_clause`. + // Time-range bucket selection (v1 only; the v0 CBOR surface is + // unaffected). `field` names a timestamp covered by a `timeRange` + // index. Operand: `text` selector `"newest"`/`"oldest"` when one grid + // buckets the field, or `list` `[selector, range, step(, phase)]` in + // the contract's declared seconds to name one of several grids (zero + // phase is spelled by omission — one wire spelling per grid). The + // server resolves it to a bucket-start equality from current block + // time; the verifier re-derives the same bucket from the quorum-signed + // metadata time — an ordinary index/count proof. See `timeRange` in + // the document meta-schema and + // `drive::query::resolve_time_range_bucket_clause`. IN_TIME_RANGE = 11; } From a317c8620311800c1f442d67d4769578453c16dc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 11:14:09 +0200 Subject: [PATCH 08/20] test: deterministic time-range fixtures; direct WASM grid-parser coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: the time-range storage and uniqueness tests now derive every owner/document/contract identifier deterministically from the fixture's own inputs (fixture_bytes marker+timestamp+tag) instead of OS entropy — a failing GroveDB fixture reproduces identically run-to-run and the getrandom unwrap leaves consensus-sensitive tests. The WASM parse_time_range_clause boundary — the only converter from the public JS { field, selector, grid? } shape — gains direct parser tests: bare selector, zero-phase-by-omission and explicit-phase grids, invalid selectors, malformed grids, and negative/fractional members. Co-Authored-By: Claude Fable 5 --- .../class_methods/try_from_schema/mod.rs | 1 - .../drive/document/index_uniqueness/mod.rs | 23 +++- .../insert/add_document_for_contract/mod.rs | 49 +++++--- packages/wasm-sdk/src/queries/document.rs | 114 ++++++++++++++++++ 4 files changed, 165 insertions(+), 22 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 83f4866c288..bd45f698359 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -852,7 +852,6 @@ mod tests { .expect("a parse predating refersTo should ignore the keyword entirely"); } - // ================================================================ // requiredSince // ================================================================ diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 9af3360aee8..faf6228b017 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -727,7 +727,6 @@ mod unique_time_range_index_tests { use dpp::identifier::Identifier; use dpp::platform_value::{platform_value, Value}; use dpp::prelude::DataContract; - use dpp::tests::utils::generate_random_identifier_struct; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; @@ -745,6 +744,22 @@ mod unique_time_range_index_tests { /// bucket start is a millisecond timestamp. const DAY_SECONDS: u64 = 24 * 3_600; const DAY_MS: u64 = 24 * 3_600_000; + + /// Deterministic 32-byte fixture identifier derived from the document's + /// own fixture inputs. Identifiers here are plumbing, not test inputs: + /// fixed bytes keep a failing GroveDB fixture reproducible run-to-run and + /// avoid an OS-entropy dependency (and its unwrap) in + /// consensus-sensitive tests. `marker` separates namespaces (document id + /// vs owner) and same-timestamp siblings. + fn fixture_bytes(marker: u8, created_at: u64, tag: &str) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[0] = marker; + bytes[1..9].copy_from_slice(&created_at.to_be_bytes()); + for (i, byte) in tag.bytes().take(23).enumerate() { + bytes[9 + i] = byte; + } + bytes + } /// Start of the window every "same window" timestamp below lands in — /// a whole number of days, so it is a bucket start on the default /// (phase 0) daily grid. @@ -804,7 +819,7 @@ mod unique_time_range_index_tests { }); let schemas = platform_value!({ "report": document_schema }); factory - .create_with_value_config(generate_random_identifier_struct(), 0, schemas, None, None) + .create_with_value_config(Identifier::from([201u8; 32]), 0, schemas, None, None) .expect("create contract") .data_contract_owned() } @@ -841,9 +856,9 @@ mod unique_time_range_index_tests { platform_version: &PlatformVersion, ) -> Identifier { let document_type = contract.document_type_for_name("report").expect("report"); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, created_at, author); let document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, created_at, author)), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("author".to_string(), Value::Text(author.to_string()))]), created_at: Some(created_at), diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index b64e1d6ea96..ccb67d7431b 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -89,7 +89,6 @@ mod time_range_index_e2e_tests { use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::platform_value::{platform_value, Identifier, Value}; use dpp::prelude::DataContract; - use dpp::tests::utils::generate_random_identifier_struct; use dpp::version::PlatformVersion; use std::collections::BTreeMap; @@ -100,6 +99,22 @@ mod time_range_index_e2e_tests { const HOUR_SECONDS: u64 = 3_600; const HOUR_MS: u64 = 3_600_000; + /// Deterministic 32-byte fixture identifier derived from the document's + /// own fixture inputs. Identifiers here are plumbing, not test inputs: + /// fixed bytes keep a failing GroveDB fixture reproducible run-to-run and + /// avoid an OS-entropy dependency (and its unwrap) in + /// consensus-sensitive tests. `marker` separates namespaces (document id + /// vs owner) and same-timestamp siblings. + fn fixture_bytes(marker: u8, created_at: u64, tag: &str) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[0] = marker; + bytes[1..9].copy_from_slice(&created_at.to_be_bytes()); + for (i, byte) in tag.bytes().take(23).enumerate() { + bytes[9 + i] = byte; + } + bytes + } + /// A latest-protocol `post` document type with a `(timeRange($createdAt, range=6h, /// step=2h), hashtag)` countable index — i.e. trending hashtags over a /// 6-hour window refreshed every 2 hours (overlap factor 3). @@ -151,7 +166,7 @@ mod time_range_index_e2e_tests { "additionalProperties": false, }); let schemas = platform_value!({ "post": document_schema }); - let owner_id = generate_random_identifier_struct(); + let owner_id = Identifier::from([201u8; 32]); factory .create_with_value_config(owner_id, 0, schemas, None, None) .expect("create contract") @@ -276,9 +291,9 @@ mod time_range_index_e2e_tests { vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS] ); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, created_at, "ibiza"); let document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), created_at: Some(created_at), @@ -396,9 +411,9 @@ mod time_range_index_e2e_tests { vec![12 * HOUR_MS, 10 * HOUR_MS, 8 * HOUR_MS] ); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, first_created_at, "ibiza"); let mut document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, first_created_at, "ibiza")), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), created_at: Some(first_created_at), @@ -597,7 +612,7 @@ mod time_range_index_e2e_tests { "additionalProperties": false, }); let schemas = platform_value!({ "post": document_schema }); - let owner_id = generate_random_identifier_struct(); + let owner_id = Identifier::from([201u8; 32]); factory .create_with_value_config(owner_id, 0, schemas, None, None) .expect("create contract") @@ -613,9 +628,9 @@ mod time_range_index_e2e_tests { platform_version: &PlatformVersion, ) { let document_type = contract.document_type_for_name("post").expect("post"); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, created_at, hashtag); let document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, created_at, hashtag)), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("hashtag".to_string(), Value::Text(hashtag.to_string()))]), created_at: Some(created_at), @@ -881,7 +896,7 @@ mod time_range_index_e2e_tests { "additionalProperties": false, }); let schemas = platform_value!({ "report": document_schema }); - let owner_id = generate_random_identifier_struct(); + let owner_id = Identifier::from([201u8; 32]); factory .create_with_value_config(owner_id, 0, schemas, None, None) .expect("create contract") @@ -971,9 +986,9 @@ mod time_range_index_e2e_tests { .expect("a post-origin timestamp has exactly one bucket"); assert_eq!(bucket, 100 * DAY_MS); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, created_at, "alice"); let mut document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, created_at, "alice")), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), created_at: Some(created_at), @@ -1044,9 +1059,9 @@ mod time_range_index_e2e_tests { // The vacated slot is genuinely free again: a second document may take // it, which only holds if the update actually removed the reference // rather than leaving a stale one behind. - let second_owner = rand::random::<[u8; 32]>(); + let second_owner = fixture_bytes(3, created_at, "alice"); let second = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(4, created_at, "alice")), owner_id: Identifier::from(second_owner), properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), created_at: Some(created_at + HOUR_MS), @@ -1179,7 +1194,7 @@ mod time_range_index_e2e_tests { }); let schemas = platform_value!({ "post": document_schema }); factory - .create_with_value_config(generate_random_identifier_struct(), 0, schemas, None, None) + .create_with_value_config(Identifier::from([202u8; 32]), 0, schemas, None, None) .expect("a contract may bucket one timestamp with several grids") .data_contract_owned() } @@ -1249,9 +1264,9 @@ mod time_range_index_e2e_tests { "the same numeric start on both grids is the point of this fixture" ); - let owner_bytes = rand::random::<[u8; 32]>(); + let owner_bytes = fixture_bytes(1, created_at, "ibiza"); let document = Document::V0(DocumentV0 { - id: Identifier::from(rand::random::<[u8; 32]>()), + id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), owner_id: Identifier::from(owner_bytes), properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), created_at: Some(created_at), diff --git a/packages/wasm-sdk/src/queries/document.rs b/packages/wasm-sdk/src/queries/document.rs index 731ff41ff2f..95b39c4d10c 100644 --- a/packages/wasm-sdk/src/queries/document.rs +++ b/packages/wasm-sdk/src/queries/document.rs @@ -1144,3 +1144,117 @@ fn split_averages_to_js_map(splits: Option) -> Result Date: Wed, 26 Aug 2026 13:43:09 +0200 Subject: [PATCH 09/20] fix: address review findings on the time-range query surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the current head, none consensus-visible before protocol v14 activates: - Move validate_and_canonicalize_where_clauses (+ range-pair merge) from the count dispatcher into shared drive::query::canonicalize, and run it in the sum dispatcher, the average prove path, and the SDK count / sum / average proof verifiers — a [f > A, f < B] pair now behaves identically on every aggregate route and its proofs verify client-side. - Port sum's strict prefix-coverage guard to count's carrier-arm index picker: an equality on a field the index does not carry no longer silently drops (over-broad per-group counts that even verified). - Collapse the duplicated bucketed-source shape guards into one DriveDocumentQuery::validate_resolved_source_shape, run on both find_best_index and the multiple-In selection — the multi-In execution lowering previously bypassed the guard entirely (direct-Rust-only hole). - Gate IN_TIME_RANGE emission on the v14 contract grammar generation and correct the "Platform v3.1+" doc/error text (the operator otherwise reached PV12/13 servers as an unknown discriminant); tests pin v13 refusal vs v14 acceptance. - Derive the fee re-parser's ranked/timeRange grammar admissions from a shared IndexGrammarAdmissions::for_schema_generation mapping also used by the schema parsers, so fee and validation parsing cannot drift. - Fix two #[cfg] gates orphaned by inserted imports in the sum/average query modules (warnings under --no-default-features --features verify). - Replace the overlap-factor clamp(1, max) with min/max (Ord::clamp panics if a future limits table carries Some(0)). - Make Index::objects_are_conflicting compare bucket starts for a time-range source (same-bucket/adjacent-bucket/sliver tests). - Fail loudly in the uniqueness probe on a non-timestamp source value instead of silently skipping the index's check; treat non-8-byte values as undecodable in entry_keys_for_raw (no truncated-prefix bucketing). - Fetch the contract once per v1 document query: shared fetch helper + PrefetchedContract threaded into the aggregate dispatchers (after their cheap shape guards, preserving error precedence), replacing five copies of the parse → fetch → not-found block and the resolution re-fetch; guard the where-clause partition behind an .any() check. - Drop per-bucket path clones in the insert/delete v2 walkers (dead path_key_info clone; final bucket takes ownership of index_path). - Document the wasm-drive-verify time-range limitation and extend the no-covering-index error to name bucketed indexes when one exists. - Readability: import instead of inline-qualifying multi-segment paths at call sites (normalize_time_range_clauses_with_metadata_time and friends). Co-Authored-By: Claude Fable 5 --- .../src/documents/average_proof_helpers.rs | 20 +- .../src/documents/count_proof_helpers.rs | 20 +- .../src/documents/document_query.rs | 176 ++++++++++++---- .../src/documents/having_proof_helpers.rs | 6 +- .../src/documents/ranked_proof_helpers.rs | 6 +- .../src/documents/sum_proof_helpers.rs | 20 +- .../class_methods/try_from_schema/v1/mod.rs | 4 +- .../class_methods/try_from_schema/v3/mod.rs | 13 +- .../data_contract/document_type/index/mod.rs | 129 +++++++++++- .../document_type/index/time_range.rs | 19 +- .../methods/registration_cost/v1/mod.rs | 38 ++-- .../document_query/v1/dispatch/average.rs | 31 +-- .../query/document_query/v1/dispatch/count.rs | 31 +-- .../document_query/v1/dispatch/having.rs | 32 +-- .../document_query/v1/dispatch/ranked.rs | 32 +-- .../query/document_query/v1/dispatch/sum.rs | 31 +-- .../src/query/document_query/v1/mod.rs | 120 +++++++++-- .../src/query/document_query/v1/tests.rs | 9 +- .../v2/mod.rs | 15 +- .../drive/document/index_level_tree_types.rs | 4 +- .../validate_uniqueness_of_data/v1/mod.rs | 43 +++- .../insert/add_document_for_contract/mod.rs | 98 ++++++--- .../v2/mod.rs | 17 +- packages/rs-drive/src/query/canonicalize.rs | 190 ++++++++++++++++++ .../drive_dispatcher.rs | 17 +- .../query/drive_document_average_query/mod.rs | 3 +- .../drive_dispatcher.rs | 9 +- .../drive_dispatcher.rs | 182 +---------------- .../index_picker.rs | 16 +- .../query/drive_document_count_query/tests.rs | 88 +++++++- .../drive_dispatcher.rs | 38 ++-- .../src/query/drive_document_sum_query/mod.rs | 3 +- packages/rs-drive/src/query/mod.rs | 160 ++++++++------- .../multiple_in_path_query/v0/mod.rs | 14 +- .../src/document/verify_proof.rs | 6 + .../document/verify_proof_keep_serialized.rs | 6 + .../verify_start_at_document_in_proof.rs | 6 + packages/wasm-sdk/src/queries/document.rs | 3 +- 38 files changed, 1106 insertions(+), 549 deletions(-) create mode 100644 packages/rs-drive/src/query/canonicalize.rs diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index 53cab1e477b..d0e99c5acf2 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -19,6 +19,7 @@ //! [`DocumentAverage`]: drive_proof_verifier::DocumentAverage //! [`DocumentSplitAverages`]: drive_proof_verifier::DocumentSplitAverages +use crate::documents::document_query::normalize_time_range_clauses_with_metadata_time; use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -33,6 +34,7 @@ use drive::query::drive_document_sum_query::index_picker::{ }; use drive::query::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; use drive::query::drive_document_sum_query::{DocumentSumMode, DriveDocumentSumQuery, SumMode}; +use drive::query::validate_and_canonicalize_where_clauses; use drive::query::{SelectFunction, WhereOperator}; use drive_proof_verifier::{ verify_aggregate_count_and_sum_proof, verify_carrier_aggregate_count_and_sum_proof, @@ -118,10 +120,20 @@ pub(super) fn verify_average_query( // ...and enforce the same provenance-vs-shape contract the server // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + normalize_time_range_clauses_with_metadata_time(&mut request, mtd.time_ms)?; + + // Canonicalize exactly as the server dispatcher does (the shared step + // in `drive::query::canonicalize`): the prover merged `[f > A, f < B]` + // pairs into one `between*` clause before its mode detection, so mode + // detection here must run over the same canonical shape or a valid + // proof is rejected. + request.where_clauses = validate_and_canonicalize_where_clauses( + std::mem::take(&mut request.where_clauses), + platform_version, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("where-clause canonicalization failed: {e}"), + })?; let document_type = request .data_contract diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index 00ebdaa3fa3..d7b28a8e7c1 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -13,6 +13,7 @@ //! [`DocumentCount`]: drive_proof_verifier::DocumentCount //! [`DocumentSplitCounts`]: drive_proof_verifier::DocumentSplitCounts +use crate::documents::document_query::normalize_time_range_clauses_with_metadata_time; use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -22,6 +23,7 @@ use dpp::{ data_contract::accessors::v0::DataContractV0Getters, data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}, }; +use drive::query::validate_and_canonicalize_where_clauses; use drive::query::{ CountMode, DocumentCountMode, DriveDocumentCountQuery, SelectFunction, WhereOperator, }; @@ -158,10 +160,20 @@ pub(super) fn verify_count_query( // ...and enforce the same provenance-vs-shape contract the server // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + normalize_time_range_clauses_with_metadata_time(&mut request, mtd.time_ms)?; + + // Canonicalize exactly as the server dispatcher does (the shared step + // in `drive::query::canonicalize`): the prover merged `[f > A, f < B]` + // pairs into one `between*` clause before its mode detection, so mode + // detection here must run over the same canonical shape or a valid + // proof is rejected. + request.where_clauses = validate_and_canonicalize_where_clauses( + std::mem::take(&mut request.where_clauses), + platform_version, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("where-clause canonicalization failed: {e}"), + })?; let document_type = request .data_contract diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 3e32e37fe81..4c727b6e625 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -33,6 +33,7 @@ use dpp::{ use drive::config::DEFAULT_QUERY_LIMIT; use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key; use drive::query::{ + resolve_time_range_bucket_clause, validate_resolved_time_range_clause_shapes, DriveDocumentQuery, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, InternalClauses, OrderClause, ResolvedTimeRange, SelectFunction, SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, WhereOperator, @@ -249,7 +250,8 @@ impl DocumentQuery { /// active range. Emitted as an `IN_TIME_RANGE` clause on the v1 wire and /// resolved server-side from the current block time; the proof verifier /// re-derives the identical bucket from the quorum-signed response - /// metadata time. Requires Platform v3.1+ (v1 wire). + /// metadata time. Requires protocol version 14+ — the first version + /// whose contract grammar hosts `timeRange` indexes. /// /// The bare selector is unambiguous only while exactly one grid buckets /// `field`; when the contract declares several grids over it, use @@ -608,13 +610,10 @@ pub(super) fn normalize_time_range_clauses_with_metadata_time( time_ms: u64, ) -> Result, drive_proof_verifier::Error> { let resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(request, time_ms)?; - drive::query::validate_resolved_time_range_clause_shapes( - &request.where_clauses, - &resolved_time_ranges, - ) - .map_err(|e| drive_proof_verifier::Error::RequestError { - error: format!("invalid time range query shape: {}", e), - })?; + validate_resolved_time_range_clause_shapes(&request.where_clauses, &resolved_time_ranges) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("invalid time range query shape: {}", e), + })?; Ok(resolved_time_ranges) } @@ -639,16 +638,11 @@ pub(super) fn resolve_time_range_clauses_with_metadata_time( grid, } in time_range_clauses { - let (clause, resolved) = drive::query::resolve_time_range_bucket_clause( - &field, - selector, - grid, - document_type, - time_ms, - ) - .map_err(|e| drive_proof_verifier::Error::RequestError { - error: format!("failed to resolve time range clause: {}", e), - })?; + let (clause, resolved) = + resolve_time_range_bucket_clause(&field, selector, grid, document_type, time_ms) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("failed to resolve time range clause: {}", e), + })?; request.where_clauses.push(clause); resolved_time_ranges.push(resolved); } @@ -663,7 +657,8 @@ pub(super) fn resolve_time_range_clauses_with_metadata_time( /// V0 lacks `selects` / `group_by` / `having` / `offset` and the /// optional-limit semantics — callers that set those features get /// `Error::Config` with a clear "requires Platform v3.1+" message -/// rather than a silently-truncated request. +/// rather than a silently-truncated request. Time-range clauses are +/// additionally gated on the v14 contract grammar — see the `1 =>` arm. impl TryFromPlatformVersioned for GetDocumentsRequest { type Error = Error; @@ -702,8 +697,8 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { 0 => { if !time_range_clauses.is_empty() { return Err(Error::Config( - "time range (IN_TIME_RANGE) queries require Platform v3.1+ (the v1 \ - getDocuments wire); the v0 wire has no time-range operator" + "time range (IN_TIME_RANGE) queries require protocol version 14+; the \ + v0 getDocuments wire has no time-range operator" .to_string(), )); } @@ -720,19 +715,42 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { &having, ) } - 1 => encode_v1( - data_contract.id().to_vec(), - document_type_name, - where_clauses, - time_range_clauses, - order_by_clauses, - limit, - offset, - start, - select, - group_by, - having, - ), + 1 => { + // The v1 wire predates time-range indexes: protocol + // versions 12 and 13 also serve it, but their contract + // grammar (document meta-schema generations 1 and 2) + // cannot host a `timeRange` index. Gate on the grammar + // generation — the same table the server's parser reads — + // rather than emitting an operator a pre-v14 server + // rejects as an unknown discriminant. + let grammar_generation = platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .document_type_schema; + if !time_range_clauses.is_empty() && grammar_generation < 3 { + return Err(Error::Config(format!( + "time range (IN_TIME_RANGE) queries require protocol version 14+ — the \ + first version whose contract grammar hosts `timeRange` indexes; this \ + network runs protocol version {}", + platform_version.protocol_version + ))); + } + encode_v1( + data_contract.id().to_vec(), + document_type_name, + where_clauses, + time_range_clauses, + order_by_clauses, + limit, + offset, + start, + select, + group_by, + having, + ) + } n => Err(Error::Config(format!( "GetDocumentsRequest wire encoder does not support feature_version={n} \ (drive_abci.query.document_query) on PlatformVersion v{}", @@ -969,7 +987,7 @@ impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { /// plain index returns a different — but validly proven — result. fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result { if !value.resolved_time_ranges.is_empty() { - return Err(crate::error::Error::Config( + return Err(Error::Config( "a drive query carrying time-range resolution provenance cannot be \ converted to a DocumentQuery: the resolved bucket equality would be \ demoted to a raw-timestamp predicate. Build the DocumentQuery with \ @@ -1369,3 +1387,91 @@ fn value_to_proto_at_depth(value: Value, depth: u8) -> Result Arc { + let schemas = platform_value!({ + "post": { + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "required": ["hashtag"], + "additionalProperties": false, + } + }); + let contract = DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("expected a factory") + .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None) + .expect("the post contract is well-formed") + .data_contract_owned(); + Arc::new(contract) + } + + fn newest_time_range_query() -> DocumentQuery { + DocumentQuery::new(post_contract(), "post") + .expect("the fixture has this document type") + .with_time_range("$createdAt", TimeRangeSelector::Newest) + } + + #[test] + fn a_time_range_query_refuses_to_encode_for_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + let error = GetDocumentsRequest::try_from_platform_versioned( + newest_time_range_query(), + platform_version, + ) + .expect_err("protocol version 13's contract grammar has no timeRange indexes"); + assert!( + error.to_string().contains("protocol version 14"), + "the refusal must name the real version floor, got: {error}" + ); + } + + #[test] + fn a_time_range_query_encodes_the_operator_for_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists"); + let request = GetDocumentsRequest::try_from_platform_versioned( + newest_time_range_query(), + platform_version, + ) + .expect("protocol version 14 hosts timeRange indexes"); + let Some(V1(v1)) = request.version else { + panic!("protocol version 14 encodes on the v1 wire"); + }; + let operators: Vec = v1 + .where_clauses + .iter() + .map(|clause| clause.operator) + .collect(); + assert_eq!( + operators, + vec![ProtoWhereOperator::InTimeRange as i32], + "the pending selector must ride as exactly one IN_TIME_RANGE clause" + ); + } + + #[test] + fn a_query_without_time_range_clauses_still_encodes_for_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + let query = DocumentQuery::new(post_contract(), "post") + .expect("the fixture has this document type"); + GetDocumentsRequest::try_from_platform_versioned(query, platform_version) + .expect("the gate only refuses queries that carry a time-range selection"); + } +} diff --git a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs index ea0056efc02..91b2e5339d8 100644 --- a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs @@ -16,6 +16,7 @@ //! //! [`DocumentHavingEntries`]: drive_proof_verifier::DocumentHavingEntries +use crate::documents::document_query::normalize_time_range_clauses_with_metadata_time; use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -111,10 +112,7 @@ pub(super) fn verify_having_query( // the requested window. Without this guard the verifier would // authenticate a request shape honest servers refuse. let resolved_time_ranges = - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + normalize_time_range_clauses_with_metadata_time(&mut request, mtd.time_ms)?; if !resolved_time_ranges.is_empty() { return Err(drive_proof_verifier::Error::RequestError { error: "a HAVING query cannot carry a time-range (IN_TIME_RANGE) selection: its \ diff --git a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index 5621531d2c5..785b2f018e8 100644 --- a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -19,6 +19,7 @@ //! //! [`DocumentRankedEntries`]: drive_proof_verifier::DocumentRankedEntries +use crate::documents::document_query::normalize_time_range_clauses_with_metadata_time; use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -141,10 +142,7 @@ pub(super) fn verify_ranked_query( // requested window — and without this guard the verifier would // authenticate it even though an honest server rejects the request. let resolved_time_ranges = - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + normalize_time_range_clauses_with_metadata_time(&mut request, mtd.time_ms)?; if !resolved_time_ranges.is_empty() { return Err(drive_proof_verifier::Error::RequestError { error: "a ranked query cannot carry a time-range (IN_TIME_RANGE) selection: \ diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index 75ca94c64cc..a91d8d83b93 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -19,6 +19,7 @@ //! [`DocumentSum`]: drive_proof_verifier::DocumentSum //! [`DocumentSplitSums`]: drive_proof_verifier::DocumentSplitSums +use crate::documents::document_query::normalize_time_range_clauses_with_metadata_time; use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -33,6 +34,7 @@ use drive::query::drive_document_sum_query::index_picker::{ }; use drive::query::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; use drive::query::drive_document_sum_query::{DocumentSumMode, DriveDocumentSumQuery, SumMode}; +use drive::query::validate_and_canonicalize_where_clauses; use drive::query::{SelectFunction, WhereOperator}; use drive_proof_verifier::{ verify_aggregate_sum_proof, verify_carrier_aggregate_sum_proof, verify_distinct_sum_proof, @@ -111,10 +113,20 @@ pub(super) fn verify_sum_query( // ...and enforce the same provenance-vs-shape contract the server // dispatchers do, through the one shared normalization helper. let resolved_time_ranges = - super::document_query::normalize_time_range_clauses_with_metadata_time( - &mut request, - mtd.time_ms, - )?; + normalize_time_range_clauses_with_metadata_time(&mut request, mtd.time_ms)?; + + // Canonicalize exactly as the server dispatcher does (the shared step + // in `drive::query::canonicalize`): the prover merged `[f > A, f < B]` + // pairs into one `between*` clause before its mode detection, so mode + // detection here must run over the same canonical shape or a valid + // proof is rejected. + request.where_clauses = validate_and_canonicalize_where_clauses( + std::mem::take(&mut request.where_clauses), + platform_version, + ) + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: format!("where-clause canonicalization failed: {e}"), + })?; let document_type = request .data_contract diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs index f322c35c812..3d948e2c05e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs @@ -96,7 +96,9 @@ impl DocumentTypeV1 { meta_schema_method_name: "DocumentTypeV1::try_from_schema (document_type_schema)", // RANKED: generation 1 predates the ranked aggregates entirely // — its index grammar has no `ranked*` keywords, and it - // therefore has no ranked key ceiling to enforce. + // therefore has no ranked key ceiling to enforce. Both + // admissions are always `false` below generation 3; see the + // shared mapping `IndexGrammarAdmissions::for_schema_generation`. admit_ranked: false, ranked_index_key_length_check: common::no_ranked_index_key_length_check, ranked_index_structure_check: common::no_ranked_index_structure_check, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index f51e9e55087..77ad1a47981 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -15,9 +15,10 @@ //! index-key length ceilings, and the constants they are derived from. use crate::data_contract::config::DataContractConfig; -// Only the ranked key-length rule below names these, and it is validation-only. +// Only the ranked key-length rule below names `Index`, and it is validation-only. #[cfg(feature = "validation")] use crate::data_contract::document_type::index::Index; +use crate::data_contract::document_type::index::IndexGrammarAdmissions; #[cfg(feature = "validation")] use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::document_type::v2::DocumentTypeV2; @@ -255,12 +256,14 @@ fn try_from_schema_generation_3( // generation is far past that boundary. admit_count_indexes: true, meta_schema_method_name: "DocumentType::try_from_schema_v3 (document_type_schema)", - // RANKED: the constants that make this generation 3. - admit_ranked: true, + // RANKED / TIME RANGE: the keyword admissions that make this + // generation 3, read from the shared generation → admission + // mapping so the registration-cost re-parse can never drift + // from what this parser accepts. + admit_ranked: IndexGrammarAdmissions::for_schema_generation(3).ranked, ranked_index_key_length_check: RANKED_INDEX_KEY_LENGTH_CHECK, ranked_index_structure_check: validate_no_ranked_prefix_overlap, - // TIME RANGE: the other keyword generation 3 adds. - admit_time_range: true, + admit_time_range: IndexGrammarAdmissions::for_schema_generation(3).time_range, }, platform_version, )?; diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index f99e640317e..85680304993 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -515,6 +515,58 @@ pub struct Index { pub time_range: Option, } +/// Which grammar keywords a document meta-schema generation admits for +/// indexes — the single source of the generation → admission mapping. +/// Both consumers of [`Index::try_from_value_map`]'s admission flags read +/// it: the schema parsers (`try_from_schema`'s per-generation modules) +/// and the registration-cost re-parse. Deriving the flags anywhere else +/// invites the two to drift, and then an index the validator parses is +/// billed nothing (or a rejected one is billed). +pub struct IndexGrammarAdmissions { + /// The `ranked*` keyword family (generation 3 and later). + pub ranked: bool, + /// The `timeRange` keyword (generation 3 and later). + pub time_range: bool, +} + +impl IndexGrammarAdmissions { + /// The admissions for a `document_type_schema` generation. The two + /// flags currently move together; they stay separate fields because + /// they are separate grammar admissions — a future generation may + /// admit one without the other, and then only this mapping changes. + pub fn for_schema_generation(generation: u16) -> Self { + Self { + ranked: generation >= 3, + time_range: generation >= 3, + } + } +} + +/// Whether two values of a bucketed timestamp property occupy a common +/// index slot. The stored key for such a property is the bucket *start*, +/// so two different timestamps conflict exactly when they share a +/// containing bucket (a validated unique time-range index has overlap +/// factor 1, so each timestamp has at most one). An epoch-sliver +/// timestamp produces no index entries and so shares no slot; a +/// non-timestamp value is stored under its raw key, so raw equality +/// applies. +fn bucketed_timestamps_conflict( + transform: &TimeRangeTransform, + value1: &Value, + value2: &Value, +) -> bool { + match (value1.as_integer::(), value2.as_integer::()) { + (Some(timestamp1), Some(timestamp2)) => { + let buckets2 = transform.containing_buckets(timestamp2); + transform + .containing_buckets(timestamp1) + .iter() + .any(|bucket| buckets2.contains(bucket)) + } + _ => value1 == value2, + } +} + impl Index { /// Check to see if two objects are conflicting pub fn objects_are_conflicting(&self, object1: &ValueMap, object2: &ValueMap) -> bool { @@ -529,7 +581,12 @@ impl Index { let Some(value2) = Value::get_optional_from_map(object2, property.name.as_str()) else { return false; }; - value1 == value2 + match self.time_range.as_ref() { + Some(transform) if property.name == transform.source => { + bucketed_timestamps_conflict(transform, value1, value2) + } + _ => value1 == value2, + } }) } /// The field names of the index @@ -2314,6 +2371,76 @@ mod tests { assert!(!index.objects_are_conflicting(&obj1, &obj2)); } + /// A daily unique grid (`range == step`, the only shape a unique + /// time-range index may take): the bucketed property occupies its + /// bucket-start slot, so conflict follows the bucket, not the raw + /// timestamp. + fn make_unique_daily_index() -> Index { + let mut index = make_index("idx", vec![("$createdAt", true), ("author", true)], true); + index.time_range = Some(TimeRangeTransform { + source: "$createdAt".to_string(), + range_seconds: 86_400, + step_seconds: 86_400, + phase_seconds: 0, + }); + index + } + + fn created_at_author(timestamp_ms: u64, author: &str) -> ValueMap { + vec![ + ( + Value::Text("$createdAt".to_string()), + Value::U64(timestamp_ms), + ), + ( + Value::Text("author".to_string()), + Value::Text(author.to_string()), + ), + ] + } + + const DAY_MS: u64 = 86_400_000; + + #[test] + fn test_objects_are_conflicting_time_range_same_bucket() { + let index = make_unique_daily_index(); + // different timestamps, same daily bucket, same suffix → same slot + let obj1 = created_at_author(3 * DAY_MS + 1_000, "sam"); + let obj2 = created_at_author(3 * DAY_MS + 2_000, "sam"); + assert!(index.objects_are_conflicting(&obj1, &obj2)); + } + + #[test] + fn test_objects_are_conflicting_time_range_adjacent_buckets() { + let index = make_unique_daily_index(); + // last ms of day 3 vs first ms of day 4: adjacent slots, no conflict + let obj1 = created_at_author(4 * DAY_MS - 1, "sam"); + let obj2 = created_at_author(4 * DAY_MS, "sam"); + assert!(!index.objects_are_conflicting(&obj1, &obj2)); + } + + #[test] + fn test_objects_are_conflicting_time_range_same_bucket_different_suffix() { + let index = make_unique_daily_index(); + let obj1 = created_at_author(3 * DAY_MS + 1_000, "sam"); + let obj2 = created_at_author(3 * DAY_MS + 2_000, "alice"); + assert!(!index.objects_are_conflicting(&obj1, &obj2)); + } + + #[test] + fn test_objects_are_conflicting_time_range_epoch_sliver_never_conflicts() { + let mut index = make_unique_daily_index(); + // a one-hour phase leaves timestamps below the anchor with no + // index entries at all — no slot exists to share, even for + // identical timestamps + if let Some(transform) = index.time_range.as_mut() { + transform.phase_seconds = 3_600; + } + let obj1 = created_at_author(1_000, "sam"); + let obj2 = created_at_author(1_000, "sam"); + assert!(!index.objects_are_conflicting(&obj1, &obj2)); + } + #[test] fn test_objects_are_conflicting_one_missing_property() { let index = make_index("idx", vec![("name", true)], true); diff --git a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs index a62551a5efc..2e78e1c7ef0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/time_range.rs @@ -266,14 +266,20 @@ impl TimeRangeTransform { /// bucket — the bucket *start*, encoded exactly like the timestamp /// itself. (A timestamp inside the sub-`step` epoch sliver before the /// phase anchor yields no keys; no real timestamp reaches it.) - /// - A non-empty value that fails to decode keeps its raw key, exactly as - /// a non-time-range index would store it. + /// - A non-empty value that is not an 8-byte encoded timestamp keeps its + /// raw key, exactly as a non-time-range index would store it. pub fn entry_keys_for_raw(&self, raw: &[u8]) -> Vec> { use crate::data_contract::document_type::DocumentPropertyType; if raw.is_empty() { return vec![Vec::new()]; } - match DocumentPropertyType::decode_date_timestamp(raw) { + // Gate on the exact encoded-timestamp width: `decode_date_timestamp` + // reads the first 8 bytes of any longer input, which would bucket a + // longer value by a truncated prefix instead of keeping its raw key. + let timestamp = (raw.len() == 8) + .then(|| DocumentPropertyType::decode_date_timestamp(raw)) + .flatten(); + match timestamp { Some(timestamp) => self .containing_buckets(timestamp) .into_iter() @@ -414,8 +420,13 @@ mod tests { DocumentPropertyType::encode_date_timestamp(2 * h), ] ); - // an undecodable non-empty value keeps its raw key + // a non-timestamp non-empty value keeps its raw key: shorter than + // the 8-byte encoding… assert_eq!(t.entry_keys_for_raw(&[1, 2, 3]), vec![vec![1, 2, 3]]); + // …and longer too — it must NOT bucket by its first 8 bytes + let mut long = DocumentPropertyType::encode_date_timestamp(7 * h); + long.push(0xFF); + assert_eq!(t.entry_keys_for_raw(&long), vec![long.clone()]); // a timestamp inside the epoch sliver belongs to no range: no keys let t_phased = TimeRangeTransform { source: "$createdAt".to_string(), diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs index c52b08c235b..c3ab4962f40 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs @@ -3,7 +3,7 @@ use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; -use crate::data_contract::document_type::Index; +use crate::data_contract::document_type::{Index, IndexGrammarAdmissions}; use crate::data_contract::serialized_version::DataContractInSerializationFormat; use crate::fee::Credits; use crate::prelude::DataContract; @@ -112,26 +112,26 @@ impl DataContractInSerializationFormat { ) { for index_value in index_values { if let Ok(index_value_map) = index_value.to_map() { - // Same ranked-keyword / timeRange gate the document - // type parser applies (`document_type_schema >= 3`, - // i.e. meta schema v3 / protocol version 14). - // Without it a PV14 index carrying - // `rankedCountable` &co. or `timeRange` would fail - // to parse here and be billed nothing, while the - // identical index parses fine during validation — - // the fee must cover every index the contract - // actually registers. - let meta_schema_v3_grammar = platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .document_type_schema - >= 3; + // Same keyword gates the document type parser + // applies, read from the one shared generation → + // admission mapping. Without them a PV14 index + // carrying `rankedCountable` &co. or `timeRange` + // would fail to parse here and be billed nothing, + // while the identical index parses fine during + // validation — the fee must cover every index the + // contract actually registers. + let admissions = IndexGrammarAdmissions::for_schema_generation( + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .document_type_schema, + ); if let Ok(index) = Index::try_from_value_map( index_value_map.as_slice(), - meta_schema_v3_grammar, - meta_schema_v3_grammar, + admissions.ranked, + admissions.time_range, ) { let base_index_fee = if index.contested_index.is_some() { fee_version.document_type_base_contested_index_registration_fee diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs index 5e253f3fc31..f868ac9bc8a 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/average.rs @@ -1,6 +1,7 @@ //! `RoutingDecision::Average` — the grouped average surface. use super::super::not_yet_implemented; +use super::super::PrefetchedContract; use crate::error::query::QueryError; use crate::error::Error; use crate::platform_types::platform::Platform; @@ -17,7 +18,6 @@ use dapi_grpc::platform::v0::get_documents_response::{ }; use dpp::check_validation_result_with_data; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; @@ -44,6 +44,7 @@ impl Platform { #[allow(clippy::too_many_arguments)] pub(in crate::query::document_query::v1) fn dispatch_average_v1( &self, + prefetched_contract: PrefetchedContract, data_contract_id: Vec, document_type_name: String, where_clauses: Vec, @@ -64,25 +65,15 @@ impl Platform { ))); } - let contract_id: Identifier = - check_validation_result_with_data!(data_contract_id.try_into().map_err(|_| { - QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string(), - ) - })); - - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info.ok_or( - QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when querying from value with contract info", - )) - )); + // The request's single contract fetch: reuse the time-range + // resolution's when it ran, fetch now otherwise — after the cheap + // shape guards above, so their rejections keep precedence. + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .contract_for_aggregate_dispatch( + prefetched_contract, + data_contract_id, + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let document_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type_name.as_str()) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs index b5871c6013e..5f4f3c921dd 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs @@ -2,6 +2,7 @@ //! per-group entries). use super::super::not_yet_implemented; +use super::super::PrefetchedContract; use crate::error::query::QueryError; use crate::error::Error; use crate::platform_types::platform::Platform; @@ -17,7 +18,6 @@ use dapi_grpc::platform::v0::get_documents_response::{ }; use dpp::check_validation_result_with_data; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; @@ -40,6 +40,7 @@ impl Platform { #[allow(clippy::too_many_arguments)] pub(in crate::query::document_query::v1) fn dispatch_count_v1( &self, + prefetched_contract: PrefetchedContract, data_contract_id: Vec, document_type_name: String, where_clauses: Vec, @@ -59,25 +60,15 @@ impl Platform { ))); } - let contract_id: Identifier = - check_validation_result_with_data!(data_contract_id.try_into().map_err(|_| { - QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string(), - ) - })); - - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info.ok_or( - QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when querying from value with contract info", - )) - )); + // The request's single contract fetch: reuse the time-range + // resolution's when it ran, fetch now otherwise — after the cheap + // shape guards above, so their rejections keep precedence. + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .contract_for_aggregate_dispatch( + prefetched_contract, + data_contract_id, + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let document_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type_name.as_str()) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs index f76c42195c4..cc874ab9c40 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/having.rs @@ -1,6 +1,7 @@ //! `RoutingDecision::HavingRange` — the boolean-HAVING range //! surface (PV14). +use super::super::PrefetchedContract; use super::{empty_ranking_proof_rejection, into_v1_ranked_entry}; use crate::error::query::QueryError; use crate::error::Error; @@ -17,10 +18,8 @@ use dapi_grpc::platform::v0::get_documents_response::{ }; use dpp::check_validation_result_with_data; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; -use drive::error::query::QuerySyntaxError; use drive::query::ResolvedTimeRange; use drive::query::{ DocumentHavingRequest, DocumentHavingResponse, HavingClause, OrderClause, SelectProjection, @@ -46,6 +45,7 @@ impl Platform { #[allow(clippy::too_many_arguments)] pub(in crate::query::document_query::v1) fn dispatch_having_v1( &self, + prefetched_contract: PrefetchedContract, data_contract_id: Vec, document_type_name: String, select: SelectProjection, @@ -61,25 +61,15 @@ impl Platform { platform_state: &PlatformState, platform_version: &PlatformVersion, ) -> Result, Error> { - let contract_id: Identifier = - check_validation_result_with_data!(data_contract_id.try_into().map_err(|_| { - QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string(), - ) - })); - - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info.ok_or( - QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when querying from value with contract info", - )) - )); + // The request's single contract fetch: reuse the time-range + // resolution's when it ran, fetch now otherwise — after the cheap + // shape guards above, so their rejections keep precedence. + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .contract_for_aggregate_dispatch( + prefetched_contract, + data_contract_id, + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let document_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type_name.as_str()) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs index 10cd5b200ed..7c990672a96 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/ranked.rs @@ -1,5 +1,6 @@ //! `RoutingDecision::Ranked` — the ranked top-k surface (PV14). +use super::super::PrefetchedContract; use super::{empty_ranking_proof_rejection, into_v1_ranked_entry}; use crate::error::query::QueryError; use crate::error::Error; @@ -16,10 +17,8 @@ use dapi_grpc::platform::v0::get_documents_response::{ }; use dpp::check_validation_result_with_data; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; -use drive::error::query::QuerySyntaxError; use drive::query::ResolvedTimeRange; use drive::query::{ DocumentRankedRequest, DocumentRankedResponse, HavingClause, OrderClause, SelectProjection, @@ -55,6 +54,7 @@ impl Platform { #[allow(clippy::too_many_arguments)] pub(in crate::query::document_query::v1) fn dispatch_ranked_v1( &self, + prefetched_contract: PrefetchedContract, data_contract_id: Vec, document_type_name: String, select: SelectProjection, @@ -70,25 +70,15 @@ impl Platform { platform_state: &PlatformState, platform_version: &PlatformVersion, ) -> Result, Error> { - let contract_id: Identifier = - check_validation_result_with_data!(data_contract_id.try_into().map_err(|_| { - QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string(), - ) - })); - - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info.ok_or( - QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when querying from value with contract info", - )) - )); + // The request's single contract fetch: reuse the time-range + // resolution's when it ran, fetch now otherwise — after the cheap + // shape guards above, so their rejections keep precedence. + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .contract_for_aggregate_dispatch( + prefetched_contract, + data_contract_id, + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let document_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type_name.as_str()) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs index 0e8ffa4f07a..b5fc7a064d1 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/sum.rs @@ -1,6 +1,7 @@ //! `RoutingDecision::Sum` — the grouped sum surface. use super::super::not_yet_implemented; +use super::super::PrefetchedContract; use crate::error::query::QueryError; use crate::error::Error; use crate::platform_types::platform::Platform; @@ -16,7 +17,6 @@ use dapi_grpc::platform::v0::get_documents_response::{ }; use dpp::check_validation_result_with_data; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::identifier::Identifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; @@ -42,6 +42,7 @@ impl Platform { #[allow(clippy::too_many_arguments)] pub(in crate::query::document_query::v1) fn dispatch_sum_v1( &self, + prefetched_contract: PrefetchedContract, data_contract_id: Vec, document_type_name: String, where_clauses: Vec, @@ -62,25 +63,15 @@ impl Platform { ))); } - let contract_id: Identifier = - check_validation_result_with_data!(data_contract_id.try_into().map_err(|_| { - QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string(), - ) - })); - - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info.ok_or( - QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when querying from value with contract info", - )) - )); + // The request's single contract fetch: reuse the time-range + // resolution's when it ran, fetch now otherwise — after the cheap + // shape guards above, so their rejections keep precedence. + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .contract_for_aggregate_dispatch( + prefetched_contract, + data_contract_id, + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let document_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type_name.as_str()) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs index 0f46507927e..58c227bff34 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs @@ -65,8 +65,10 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters as _; use dpp::prelude::Identifier as ContractIdentifier; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; +use drive::drive::contract::DataContractFetchInfo; use drive::error::query::QuerySyntaxError; -use drive::query::{CountMode, SelectProjection}; +use drive::query::{resolve_time_range_bucket_clause, CountMode, SelectProjection}; +use std::sync::Arc; /// Build a `QuerySyntaxError::Unsupported` carrying a stable /// " is not yet implemented" message. The wording is @@ -81,6 +83,12 @@ pub(super) fn not_yet_implemented(feature: &str) -> QueryError { ))) } +/// The contract fetched once per request — by time-range resolution when +/// the request carries an `IN_TIME_RANGE` clause — and handed to the +/// aggregate dispatchers so they never fetch a second time. +pub(in crate::query::document_query::v1) type PrefetchedContract = + Option<(ContractIdentifier, Arc)>; + /// Outcome of `validate_and_route` — names the path the v1 request /// will dispatch to. /// @@ -143,6 +151,64 @@ enum RoutingDecision { } impl Platform { + /// Parse the wire contract id and fetch the contract (through drive's + /// cache) with the uniform error mapping every v1 document route uses. + /// One fetch per request: time-range resolution and the aggregate + /// dispatch arms both consume this instead of carrying their own + /// copies of the parse → fetch → not-found sequence. The document-type + /// lookup stays at each consumer — it borrows from the returned `Arc`. + /// + /// Outer `Err` is an internal error; inner `Err` is the validation + /// error to hand back to the wire caller + /// (`check_validation_result_with_data!` unwraps the nesting). + #[allow(clippy::type_complexity)] + fn fetch_contract_for_document_query_v1( + &self, + data_contract_id: Vec, + platform_version: &PlatformVersion, + ) -> Result), QueryError>, Error> { + let contract_id: ContractIdentifier = match data_contract_id.try_into() { + Ok(id) => id, + Err(_) => { + return Ok(Err(QueryError::InvalidArgument( + "id must be a valid identifier (32 bytes long)".to_string(), + ))) + } + }; + let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( + contract_id.to_buffer(), + None, + true, + None, + platform_version, + )?; + let Some(contract_fetch_info) = contract_fetch_info else { + return Ok(Err(QueryError::Query( + QuerySyntaxError::DataContractNotFound("contract not found for a document query"), + ))); + }; + Ok(Ok((contract_id, contract_fetch_info))) + } + + /// The contract an aggregate dispatch arm executes against: the + /// time-range resolution's fetch when it ran, one fetch now + /// otherwise. Called by each aggregate dispatcher AFTER its cheap + /// shape guards, so their rejections keep precedence over + /// contract-lookup errors. Same nested-`Result` contract as + /// [`Self::fetch_contract_for_document_query_v1`]. + #[allow(clippy::type_complexity)] + pub(in crate::query::document_query::v1) fn contract_for_aggregate_dispatch( + &self, + prefetched: PrefetchedContract, + data_contract_id: Vec, + platform_version: &PlatformVersion, + ) -> Result), QueryError>, Error> { + match prefetched { + Some(pair) => Ok(Ok(pair)), + None => self.fetch_contract_for_document_query_v1(data_contract_id, platform_version), + } + } + pub(super) fn query_documents_v1( &self, request_v1: GetDocumentsRequestV1, @@ -196,15 +262,27 @@ impl Platform { // ordinary equality lookups. The verifier re-derives the same bucket // from the quorum-signed response metadata time, so the proof // matches. - let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = proto_where_clauses - .into_iter() - .partition(conversions::is_time_range_clause); + // The `.any()` pre-check skips the two-Vec partition on the + // overwhelmingly common clause set with no time-range operator. + let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = if proto_where_clauses + .iter() + .any(conversions::is_time_range_clause) + { + proto_where_clauses + .into_iter() + .partition(conversions::is_time_range_clause) + } else { + (Vec::new(), proto_where_clauses) + }; let mut where_clauses = match conversions::where_clauses_from_proto(normal_proto) { Ok(c) => c, Err(e) => return Ok(QueryValidationResult::new_with_error(e)), }; let mut resolved_time_ranges: Vec = Vec::new(); + // The contract fetched for time-range resolution, handed to the + // aggregate dispatchers below so a request fetches at most once. + let mut prefetched_contract: PrefetchedContract = None; if !time_range_proto.is_empty() { // LOAD-BEARING TIME SOURCE: the verifier re-derives the bucket @@ -226,23 +304,11 @@ impl Platform { ), ))), }; - let contract_id: ContractIdentifier = - check_validation_result_with_data!(data_contract_id.clone().try_into().map_err( - |_| QueryError::InvalidArgument( - "id must be a valid identifier (32 bytes long)".to_string() - ) - )); - let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee( - contract_id.to_buffer(), - None, - true, - None, - platform_version, - )?; - let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info - .ok_or(QueryError::Query(QuerySyntaxError::DataContractNotFound( - "contract not found when resolving a time range query", - )))); + let (contract_id, contract_fetch_info) = check_validation_result_with_data!(self + .fetch_contract_for_document_query_v1( + data_contract_id.clone(), + platform_version + )?); let contract_ref = &contract_fetch_info.contract; let doc_type = check_validation_result_with_data!(contract_ref .document_type_for_name(document_type.as_str()) @@ -256,7 +322,7 @@ impl Platform { Ok(parsed) => parsed, Err(e) => return Ok(QueryValidationResult::new_with_error(e)), }; - match drive::query::resolve_time_range_bucket_clause( + match resolve_time_range_bucket_clause( &field, selector, grid, @@ -278,6 +344,7 @@ impl Platform { Err(e) => return Err(e.into()), } } + prefetched_contract = Some((contract_id, contract_fetch_info)); } let order_by_clauses = match conversions::order_clauses_from_proto(proto_order_by) { Ok(c) => c, @@ -331,6 +398,10 @@ impl Platform { } match routing { + // The documents route forwards the raw id into the shared + // `query_documents_typed` helper (v0 dispatches into it too), + // which owns its contract fetch; the aggregate arms below + // consume the request's single fetch instead. RoutingDecision::Documents => self.dispatch_documents_v1( data_contract_id, document_type, @@ -344,6 +415,7 @@ impl Platform { platform_version, ), RoutingDecision::Count(mode) => self.dispatch_count_v1( + prefetched_contract, data_contract_id, document_type, where_clauses, @@ -357,6 +429,7 @@ impl Platform { platform_version, ), RoutingDecision::Sum { sum_property, mode } => self.dispatch_sum_v1( + prefetched_contract, data_contract_id, document_type, where_clauses, @@ -371,6 +444,7 @@ impl Platform { platform_version, ), RoutingDecision::Average { sum_property, mode } => self.dispatch_average_v1( + prefetched_contract, data_contract_id, document_type, where_clauses, @@ -385,6 +459,7 @@ impl Platform { platform_version, ), RoutingDecision::Ranked => self.dispatch_ranked_v1( + prefetched_contract, data_contract_id, document_type, select, @@ -401,6 +476,7 @@ impl Platform { platform_version, ), RoutingDecision::HavingRange => self.dispatch_having_v1( + prefetched_contract, data_contract_id, document_type, select, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 68603d24d6c..8d410a2bc73 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -26,6 +26,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::platform_value::{platform_value, Value}; use drive::query::WhereOperator; +use drive::query::{resolve_time_range_bucket_clause, validate_resolved_time_range_clause_shapes}; /// Build a `ProtoDocumentFieldValue` from a `dpp::platform_value::Value` /// for use inside this test module only. **Subset of the SDK's @@ -4585,7 +4586,7 @@ mod time_range_proof_verification { operator: WhereOperator::Equal, value: Value::Text(hashtag.to_string()), }]; - let (clause, resolution) = drive::query::resolve_time_range_bucket_clause( + let (clause, resolution) = resolve_time_range_bucket_clause( CREATED_AT, TimeRangeSelector::Newest, None, @@ -4600,7 +4601,7 @@ mod time_range_proof_verification { where_clauses.push(clause); let resolutions = vec![resolution]; - drive::query::validate_resolved_time_range_clause_shapes(&where_clauses, &resolutions) + validate_resolved_time_range_clause_shapes(&where_clauses, &resolutions) .expect("resolution produces exactly the one equality the guard admits"); let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( @@ -4765,7 +4766,7 @@ mod time_range_proof_verification { operator: WhereOperator::Equal, value: Value::Text("ibiza".to_string()), }]; - let (resolved_clause, resolution) = drive::query::resolve_time_range_bucket_clause( + let (resolved_clause, resolution) = resolve_time_range_bucket_clause( CREATED_AT, TimeRangeSelector::Newest, None, @@ -5479,7 +5480,7 @@ mod time_range_proof_verification { let block_time_ms = state .last_committed_block_time_ms() .expect("the fixture committed a block"); - let (clause, resolution) = drive::query::resolve_time_range_bucket_clause( + let (clause, resolution) = resolve_time_range_bucket_clause( CREATED_AT, TimeRangeSelector::Newest, None, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 92ce162e103..f95cd2c06e4 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -193,16 +193,25 @@ impl Drive { .unwrap_or(1), ); - for index_key in index_keys { + let bucket_count = index_keys.len(); + for (bucket, index_key) in index_keys.into_iter().enumerate() { + // The final bucket takes ownership of `index_path`; earlier + // buckets (only a time-range fan-out has more than one) + // clone it. + let own_index_path = if bucket + 1 == bucket_count { + std::mem::take(&mut index_path) + } else { + index_path.clone() + }; let mut index_path_info = if document_and_contract_info .owned_document_info .document_info .is_document_size() { // This is a stateless operation - PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path.clone())) + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(own_index_path)) } else { - PathInfo::PathAsVec::<0>(index_path.clone()) + PathInfo::PathAsVec::<0>(own_index_path) }; // we push the actual value of the index path diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index 025da4aec2f..e6f312c00e7 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -163,7 +163,9 @@ pub(crate) fn time_range_index_keys<'a>( }; match &document_top_field { DriveKeyInfo::KeySize(key_info) => { - let overlap = transform.overlap_factor().clamp(1, max_overlap_factor) as usize; + // Not `clamp(1, max)`: `Ord::clamp` asserts `min <= max`, so a + // future limits table carrying `Some(0)` would panic here. + let overlap = transform.overlap_factor().min(max_overlap_factor).max(1) as usize; (0..overlap) .map(|ordinal| { let mut key_info = key_info.clone(); diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs index 8011759db8c..cf802c3ca1c 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs @@ -6,7 +6,9 @@ use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_d use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use crate::query::{ + DriveDocumentQuery, InternalClauses, ResolvedTimeRange, WhereClause, WhereOperator, +}; use dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; use dpp::consensus::state::state_error::StateError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -396,15 +398,34 @@ impl Drive { // from an `Option`, so it is a // `U64`; `I64` is accepted defensively because a // non-system-timestamp source would arrive through - // the document data map. Anything else cannot be a - // millisecond timestamp, so there is no bucket to - // probe and the index cannot be violated by it. - let timestamp = match &clause.value { - Value::U64(timestamp) => Some(*timestamp), - Value::I64(timestamp) => u64::try_from(*timestamp).ok(), - _ => None, - }; - let timestamp = timestamp?; + // the document data map. Anything else is + // unreachable under a validated contract and must + // fail loudly: silently skipping this index's + // check would let the collision surface later as + // a corrupted-index insert error, because the + // write path stores a non-timestamp value under + // its raw key rather than dropping it. + let timestamp = + match &clause.value { + Value::U64(timestamp) => *timestamp, + Value::I64(timestamp) => match u64::try_from(*timestamp) { + Ok(timestamp) => timestamp, + Err(_) => return Some(Err(Error::Drive( + DriveError::CorruptedCodeExecution( + "a unique time-range index's source value must \ + be a millisecond timestamp", + ), + ))), + }, + _ => { + return Some(Err(Error::Drive( + DriveError::CorruptedCodeExecution( + "a unique time-range index's source value must be \ + a millisecond timestamp", + ), + ))) + } + }; // A validated unique time-range index has overlap // factor 1 (range == step), so any real timestamp // yields exactly one containing bucket. An empty @@ -416,7 +437,7 @@ impl Drive { // for it. let bucket_start = *transform.containing_buckets(timestamp).first()?; clause.value = platform_value!(bucket_start); - resolved_time_ranges.push(crate::query::ResolvedTimeRange { + resolved_time_ranges.push(ResolvedTimeRange { field: transform.source.clone(), transform: transform.clone(), }); diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index ccb67d7431b..d3e95911e69 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -77,7 +77,12 @@ mod time_range_index_e2e_tests { //! whichever index happens to cover the fields. use crate::config::DriveConfig; use crate::drive::Drive; - use crate::query::{DriveDocumentQuery, ResolvedTimeRange}; + use crate::error::query::QuerySyntaxError; + use crate::error::Error; + use crate::query::{ + resolve_time_range_bucket_clause, DriveDocumentQuery, ResolvedTimeRange, TimeRangeGridSpec, + TimeRangeSelector, + }; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -771,10 +776,7 @@ mod time_range_index_e2e_tests { .find_best_index(PlatformVersion::latest()) .expect_err("two resolved time-range fields cannot be served"); assert!( - matches!( - error, - crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) - ), + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), "expected an Unsupported rejection, got {error:?}" ); } @@ -837,9 +839,7 @@ mod time_range_index_e2e_tests { assert!( matches!( error, - crate::error::Error::Query( - crate::error::query::QuerySyntaxError::WhereClauseOnNonIndexedProperty(_) - ) + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) ), "expected a no-covering-index rejection, got {error:?}" ); @@ -1135,9 +1135,7 @@ mod time_range_index_e2e_tests { assert!( matches!( error, - crate::error::Error::Query( - crate::error::query::QuerySyntaxError::WhereClauseOnNonIndexedProperty(_) - ) + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) ), "expected a no-covering-index rejection, got {error:?}" ); @@ -1376,31 +1374,26 @@ mod time_range_index_e2e_tests { /// storage fork the previous test pins. #[test] fn multi_grid_resolution_requires_and_honors_a_grid_spec() { - use crate::query::TimeRangeGridSpec; - let contract = build_two_grid_contract(); let document_type = contract.document_type_for_name("post").expect("post"); let now_ms = 25 * HOUR_MS; - let error = crate::query::resolve_time_range_bucket_clause( + let error = resolve_time_range_bucket_clause( "$createdAt", - crate::query::TimeRangeSelector::Newest, + TimeRangeSelector::Newest, None, document_type, now_ms, ) .expect_err("two grids on the field make the bare selector ambiguous"); assert!( - matches!( - error, - crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) - ), + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), "expected the ambiguity rejection, got {error:?}" ); - let (clause, resolution) = crate::query::resolve_time_range_bucket_clause( + let (clause, resolution) = resolve_time_range_bucket_clause( "$createdAt", - crate::query::TimeRangeSelector::Newest, + TimeRangeSelector::Newest, Some(TimeRangeGridSpec { range_seconds: 24 * HOUR_SECONDS, step_seconds: 24 * HOUR_SECONDS, @@ -1413,9 +1406,9 @@ mod time_range_index_e2e_tests { assert_eq!(clause.value, Value::U64(24 * HOUR_MS)); assert_eq!(resolution.transform.range_seconds, 24 * HOUR_SECONDS); - let (clause, resolution) = crate::query::resolve_time_range_bucket_clause( + let (clause, resolution) = resolve_time_range_bucket_clause( "$createdAt", - crate::query::TimeRangeSelector::Newest, + TimeRangeSelector::Newest, Some(TimeRangeGridSpec { range_seconds: 6 * HOUR_SECONDS, step_seconds: 2 * HOUR_SECONDS, @@ -1433,9 +1426,9 @@ mod time_range_index_e2e_tests { ); assert_eq!(resolution.transform.step_seconds, 2 * HOUR_SECONDS); - let error = crate::query::resolve_time_range_bucket_clause( + let error = resolve_time_range_bucket_clause( "$createdAt", - crate::query::TimeRangeSelector::Newest, + TimeRangeSelector::Newest, Some(TimeRangeGridSpec { range_seconds: 12 * HOUR_SECONDS, step_seconds: 12 * HOUR_SECONDS, @@ -1446,11 +1439,58 @@ mod time_range_index_e2e_tests { ) .expect_err("a grid no index declares must be refused"); assert!( - matches!( - error, - crate::error::Error::Query(crate::error::query::QuerySyntaxError::Unsupported(_)) - ), + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), "expected the unknown-grid rejection, got {error:?}" ); } + + /// The multiple-`In` execution lowering picks its index directly + /// (without `find_best_index`), so it must run the shared + /// resolved-source shape guard itself: a direct caller pairing + /// fabricated provenance with an `In` clause ON the bucketed source + /// would otherwise have its raw `In` values serialized as bucket + /// keys — a validly-proven answer over arbitrary buckets. + #[test] + fn multiple_in_route_refuses_an_in_clause_on_the_bucketed_source() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("in".to_string()), + Value::Array(vec![Value::U64(2 * HOUR_MS), Value::U64(4 * HOUR_MS)]), + ]), + Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("in".to_string()), + Value::Array(vec![ + Value::Text("dash".to_string()), + Value::Text("evo".to_string()), + ]), + ]), + ]), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + &contract, + document_type, + &DriveConfig::default(), + platform_version, + ) + .expect("two In clauses are a valid protocol-version-14 query shape"); + query.resolved_time_ranges = created_at_resolution(document_type); + + let error = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect_err("an In on the bucketed source must not reach bucket keys"); + assert!( + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), + "expected the source-shape rejection, got {error:?}" + ); + } } diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 5389953abcd..fde3fc84f34 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -220,11 +220,12 @@ impl Drive { .unwrap_or(1), ); - for index_key in index_keys { + let bucket_count = index_keys.len(); + for (bucket, index_key) in index_keys.into_iter().enumerate() { // The zero will not matter here, because the PathKeyInfo is variable let path_key_info = index_key.clone().add_path::<0>(index_path.clone()); self.batch_insert_empty_tree_if_not_exists( - path_key_info.clone(), + path_key_info, value_tree_type, storage_flags, value_apply_type, @@ -234,15 +235,23 @@ impl Drive { drive_version, )?; + // The final bucket takes ownership of `index_path`; earlier + // buckets (only a time-range fan-out has more than one) + // clone it. + let own_index_path = if bucket + 1 == bucket_count { + std::mem::take(&mut index_path) + } else { + index_path.clone() + }; let mut index_path_info = if document_and_contract_info .owned_document_info .document_info .is_document_size() { // This is a stateless operation - PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path.clone())) + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(own_index_path)) } else { - PathInfo::PathAsVec::<0>(index_path.clone()) + PathInfo::PathAsVec::<0>(own_index_path) }; // we push the actual value of the index path diff --git a/packages/rs-drive/src/query/canonicalize.rs b/packages/rs-drive/src/query/canonicalize.rs new file mode 100644 index 00000000000..f8b57f510c7 --- /dev/null +++ b/packages/rs-drive/src/query/canonicalize.rs @@ -0,0 +1,190 @@ +//! Shared where-clause validation + canonicalization for the aggregate +//! query surfaces (count / sum / average / joint count-and-sum) and +//! their SDK proof verifiers. +//! +//! Lives outside the per-surface dispatcher modules because the shape +//! contract must be identical on every route: the server dispatchers +//! canonicalize before mode detection, and the proof verifiers must run +//! the very same canonicalization before *their* mode detection or a +//! proof the server produced for the canonical shape is rejected +//! client-side (the count dispatcher promises callers "the bounded form +//! and the pre-merged form get equivalent mode detection" — that promise +//! only holds if verifiers canonicalize too). + +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::drive_document_count_query::DriveDocumentCountQuery; +use crate::query::WhereClause; +use dpp::version::PlatformVersion; + +/// Run the system-wide where-clause validator on a structured +/// `Vec` and canonicalize same-field range pairs into +/// their `between*` form. Single source of truth for the aggregate +/// shape contract; called by the count / sum / average / joint +/// dispatchers, the legacy CBOR-decoded count entry, and the SDK +/// count / sum / average proof verifiers. +/// +/// The validator (`WhereClause::group_clauses`) rejects: +/// - Duplicate `Equal` clauses on the same field +/// (`DuplicateNonGroupableClauseSameField`). +/// - Multiple `In` clauses (`MultipleInClauses`) — rejected here: the +/// shared grammar accepts them for protocol version 14+ document +/// queries, but the aggregate surfaces do not. +/// - Multiple non-groupable range clauses (`MultipleRangeClauses`). +/// - Equality + `In` on the same field, range + equality/In on the +/// same field (`DuplicateNonGroupableClauseSameField` / +/// `InvalidWhereClauseComponents`). +/// +/// Without this validation, downstream +/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`] +/// collapses repeated fields into a `BTreeSet` and +/// [`DriveDocumentCountQuery::point_lookup_count_path_query`] +/// resolves each index property with a single `.find(...)` — both +/// of which silently pick the first clause on a duplicated field +/// and return a count for an arbitrarily reduced query rather than +/// rejecting the malformed request. +/// +/// **Exception**: `MultipleRangeClauses` is intentionally tolerated +/// here. The regular-query parser rejects two ranges on different +/// fields wholesale (its callers expect +/// `(equal_clauses, in_clause, range_clause)` triples), but the +/// count-query path accepts the carrier-aggregate shape +/// (`outer_range + inner_ACOR_range` on different fields, e.g. +/// G8). Structural validation for that shape lives in +/// [`DriveDocumentCountQuery::detect_mode`] (which knows about +/// `CountMode::GroupByRange`-with-two-ranges and routes to +/// `DocumentCountMode::RangeAggregateCarrierProof`); replicating +/// it here would be redundant. +/// +/// After validation, [`merge_same_field_range_pairs`] collapses +/// `[field > A, field < B]` (and analogous pairs with `>=` / `<=`) +/// into the canonical `between*` operator that +/// [`DriveDocumentCountQuery::range_clause_to_query_item`] knows +/// how to convert into a single `QueryItem`. The regular-query +/// parser does the same merge before its grouped-triple +/// validation; for aggregate queries we do it explicitly here so +/// callers can pass either the bounded form (e.g. +/// `[brand > A, brand < B]`) or the pre-merged form (e.g. +/// `[brand BetweenExcludeBounds [A, B]]`) and get equivalent +/// mode detection downstream. Without this merge, G8a's natural +/// wire shape (four range clauses, two per field) would slip past +/// the catch-`MultipleRangeClauses` block above and then get +/// rejected by `detect_mode`'s `range_count > 1` structural check. +pub fn validate_and_canonicalize_where_clauses( + clauses: Vec, + platform_version: &PlatformVersion, +) -> Result, Error> { + match WhereClause::group_clauses(&clauses, platform_version) { + // Multiple `In` clauses are a document-query-only shape (protocol + // version 14+); the aggregate surfaces keep rejecting them since + // their mode detection and index pickers assume a single `In`. + Ok((_, _, in_clauses)) if in_clauses.len() > 1 => { + return Err(Error::Query(QuerySyntaxError::MultipleInClauses( + "aggregate queries support at most one in clause", + ))); + } + Ok(_) => {} + Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(_))) => {} + Err(e) => return Err(e), + } + merge_same_field_range_pairs(clauses) +} + +/// Collapse `[field > A, field < B]` (and analogous pairs with +/// `>=` / `<=`) into a single `field between* [A, B]` clause per +/// field. Equality / In clauses pass through unchanged. +/// +/// Returns an error if a field has more than two range clauses +/// (structurally meaningless — a third bound would either +/// contradict an existing one or be redundant) or if the pair +/// isn't one lower-bound + one upper-bound (e.g. two `>` on the +/// same field). +fn merge_same_field_range_pairs(clauses: Vec) -> Result, Error> { + use crate::query::conditions::WhereOperator::{ + Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, GreaterThan, + GreaterThanOrEquals, LessThan, LessThanOrEquals, + }; + use std::collections::BTreeMap; + + let mut by_field: BTreeMap> = BTreeMap::new(); + let mut non_range: Vec = Vec::new(); + for wc in clauses { + if DriveDocumentCountQuery::is_range_operator(wc.operator) { + by_field.entry(wc.field.clone()).or_default().push(wc); + } else { + non_range.push(wc); + } + } + let mut result = non_range; + for (field, mut ranges) in by_field { + match ranges.len() { + 0 => {} + 1 => result.push(ranges.remove(0)), + 2 => { + let (mut lower, mut upper): (Option, Option) = + (None, None); + for r in ranges { + match r.operator { + GreaterThan | GreaterThanOrEquals => { + if lower.is_some() { + return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "two lower-bound range clauses on the same field cannot be \ + merged; combine via `between*` or remove the redundant clause", + ))); + } + lower = Some(r); + } + LessThan | LessThanOrEquals => { + if upper.is_some() { + return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "two upper-bound range clauses on the same field cannot be \ + merged; combine via `between*` or remove the redundant clause", + ))); + } + upper = Some(r); + } + _ => { + // The other range operators (Between*, + // StartsWith) are themselves bounded + // already; a second range clause on the + // same field is structurally redundant. + return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "cannot pair a `between*`/`startsWith` range clause with \ + another range on the same field; use the pre-merged form", + ))); + } + } + } + let lower = lower.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "two range clauses on the same field require one lower bound (> or >=) \ + and one upper bound (< or <=)", + )))?; + let upper = upper.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "two range clauses on the same field require one lower bound (> or >=) \ + and one upper bound (< or <=)", + )))?; + let merged_op = match ( + lower.operator == GreaterThanOrEquals, + upper.operator == LessThanOrEquals, + ) { + (true, true) => Between, // [a, b] + (false, false) => BetweenExcludeBounds, // (a, b) + (true, false) => BetweenExcludeRight, // [a, b) + (false, true) => BetweenExcludeLeft, // (a, b] + }; + result.push(WhereClause { + field, + operator: merged_op, + value: dpp::platform_value::Value::Array(vec![lower.value, upper.value]), + }); + } + _ => { + return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( + "more than two range clauses on the same field are not supported; a \ + bounded range needs exactly one lower bound and one upper bound", + ))); + } + } + } + Ok(result) +} diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 9ef360467d4..339255b45b4 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -55,6 +55,9 @@ use crate::query::drive_document_sum_query::index_picker::{ find_range_summable_index_for_where_clauses, find_summable_index_for_where_clauses, }; use crate::query::drive_document_sum_query::{is_range_operator, DriveDocumentSumQuery}; +use crate::query::{ + validate_and_canonicalize_where_clauses, validate_resolved_time_range_clause_shapes, +}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::version::PlatformVersion; @@ -72,7 +75,7 @@ impl Drive { /// dispatcher that reads `(count, sum)` together. pub fn execute_document_average_request( &self, - request: DocumentAverageRequest, + mut request: DocumentAverageRequest, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { @@ -84,11 +87,18 @@ impl Drive { // that counts a document once per overlapping bucket. The guard only // inspects Equal clauses, so running it before range-pair // canonicalization is equivalent to running it after. - crate::query::validate_resolved_time_range_clause_shapes( + validate_resolved_time_range_clause_shapes( &request.where_clauses, &request.resolved_time_ranges, )?; if request.prove { + // The no-prove path canonicalizes inside the joint dispatcher; + // run the identical shared step (see + // [`crate::query::canonicalize`]) on the prove path so both + // accept the bounded pair form (`[f > A, f < B]`) as well as + // the pre-merged `between*` form. + request.where_clauses = + validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; return self.execute_document_average_prove(request, transaction, platform_version); } self.execute_document_count_and_sum_request(request, transaction, platform_version) @@ -409,6 +419,7 @@ impl Drive { #[cfg(all(test, feature = "server"))] mod tests { use super::*; + use crate::query::ResolvedTimeRange; // ── Dispatcher limit-policy regression tests ─────────────────── // @@ -2012,7 +2023,7 @@ mod tests { limit: None, prove: true, drive_config: &drive_config, - resolved_time_ranges: vec![crate::query::ResolvedTimeRange { + resolved_time_ranges: vec![ResolvedTimeRange { field: "$createdAt".to_string(), transform: dpp::data_contract::document_type::TimeRangeTransform { source: "$createdAt".to_string(), diff --git a/packages/rs-drive/src/query/drive_document_average_query/mod.rs b/packages/rs-drive/src/query/drive_document_average_query/mod.rs index ea73c2adbb3..35d7975b311 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/mod.rs @@ -30,8 +30,7 @@ pub mod drive_dispatcher; #[cfg(feature = "server")] -use crate::query::ResolvedTimeRange; -use crate::query::{OrderClause, WhereClause}; +use crate::query::{OrderClause, ResolvedTimeRange, WhereClause}; #[cfg(feature = "server")] use crate::config::DriveConfig; diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index b6ca555aa66..2dd988f08e9 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -37,11 +37,13 @@ use super::super::drive_document_average_query::{ AverageMode, DocumentAverageRequest, DocumentAverageResponse, }; -use super::super::drive_document_count_query::drive_dispatcher::validate_and_canonicalize_where_clauses; use super::super::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; use super::super::drive_document_sum_query::{DocumentSumMode, SumMode}; use crate::drive::Drive; use crate::error::Error; +use crate::query::{ + validate_and_canonicalize_where_clauses, validate_resolved_time_range_clause_shapes, +}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::version::PlatformVersion; @@ -112,10 +114,7 @@ impl Drive { let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; let resolved_time_ranges = request.resolved_time_ranges; - crate::query::validate_resolved_time_range_clause_shapes( - &where_clauses, - &resolved_time_ranges, - )?; + validate_resolved_time_range_clause_shapes(&where_clauses, &resolved_time_ranges)?; // Convert AverageMode → SumMode (1:1 by construction); sum's // routing table is the single source of truth for the diff --git a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs index 17159966086..7f865587fc9 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/drive_dispatcher.rs @@ -23,6 +23,11 @@ use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::ResolvedTimeRange; +// Shared with the sum / average / joint dispatchers and the SDK proof +// verifiers — see `crate::query::canonicalize` for the shape contract. +use crate::query::{ + validate_and_canonicalize_where_clauses, validate_resolved_time_range_clause_shapes, +}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; @@ -203,178 +208,6 @@ pub fn where_clauses_from_value( validate_and_canonicalize_where_clauses(clauses, platform_version) } -/// Run the system-wide where-clause validator on a structured -/// `Vec` and canonicalize same-field range pairs into -/// their `between*` form. Single source of truth for the -/// count-endpoint shape contract; called both from the legacy -/// CBOR-decoded entry [`where_clauses_from_value`] and from the -/// dispatcher's typed entry, [`Drive::execute_document_count_request`]. -/// -/// The validator (`WhereClause::group_clauses`) rejects: -/// - Duplicate `Equal` clauses on the same field -/// (`DuplicateNonGroupableClauseSameField`). -/// - Multiple `In` clauses (`MultipleInClauses`) — rejected here: the -/// shared grammar accepts them for protocol version 14+ document -/// queries, but the aggregate surfaces do not. -/// - Multiple non-groupable range clauses (`MultipleRangeClauses`). -/// - Equality + `In` on the same field, range + equality/In on the -/// same field (`DuplicateNonGroupableClauseSameField` / -/// `InvalidWhereClauseComponents`). -/// -/// Without this validation, downstream -/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`] -/// collapses repeated fields into a `BTreeSet` and -/// [`DriveDocumentCountQuery::point_lookup_count_path_query`] -/// resolves each index property with a single `.find(...)` — both -/// of which silently pick the first clause on a duplicated field -/// and return a count for an arbitrarily reduced query rather than -/// rejecting the malformed request. -/// -/// **Exception**: `MultipleRangeClauses` is intentionally tolerated -/// here. The regular-query parser rejects two ranges on different -/// fields wholesale (its callers expect -/// `(equal_clauses, in_clause, range_clause)` triples), but the -/// count-query path accepts the carrier-aggregate shape -/// (`outer_range + inner_ACOR_range` on different fields, e.g. -/// G8). Structural validation for that shape lives in -/// [`DriveDocumentCountQuery::detect_mode`] (which knows about -/// `CountMode::GroupByRange`-with-two-ranges and routes to -/// `DocumentCountMode::RangeAggregateCarrierProof`); replicating -/// it here would be redundant. -/// -/// After validation, [`merge_same_field_range_pairs`] collapses -/// `[field > A, field < B]` (and analogous pairs with `>=` / `<=`) -/// into the canonical `between*` operator that -/// [`DriveDocumentCountQuery::range_clause_to_query_item`] knows -/// how to convert into a single `QueryItem`. The regular-query -/// parser does the same merge before its grouped-triple -/// validation; for count queries we do it explicitly here so -/// callers can pass either the bounded form (e.g. -/// `[brand > A, brand < B]`) or the pre-merged form (e.g. -/// `[brand BetweenExcludeBounds [A, B]]`) and get equivalent -/// mode detection downstream. Without this merge, G8a's natural -/// wire shape (four range clauses, two per field) would slip past -/// the catch-`MultipleRangeClauses` block above and then get -/// rejected by `detect_mode`'s `range_count > 1` structural check. -pub fn validate_and_canonicalize_where_clauses( - clauses: Vec, - platform_version: &PlatformVersion, -) -> Result, Error> { - match WhereClause::group_clauses(&clauses, platform_version) { - // Multiple `In` clauses are a document-query-only shape (protocol - // version 14+); the aggregate surfaces keep rejecting them since - // their mode detection and index pickers assume a single `In`. - Ok((_, _, in_clauses)) if in_clauses.len() > 1 => { - return Err(Error::Query(QuerySyntaxError::MultipleInClauses( - "aggregate queries support at most one in clause", - ))); - } - Ok(_) => {} - Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(_))) => {} - Err(e) => return Err(e), - } - merge_same_field_range_pairs(clauses) -} - -/// Collapse `[field > A, field < B]` (and analogous pairs with -/// `>=` / `<=`) into a single `field between* [A, B]` clause per -/// field. Equality / In clauses pass through unchanged. -/// -/// Returns an error if a field has more than two range clauses -/// (structurally meaningless — a third bound would either -/// contradict an existing one or be redundant) or if the pair -/// isn't one lower-bound + one upper-bound (e.g. two `>` on the -/// same field). -fn merge_same_field_range_pairs(clauses: Vec) -> Result, Error> { - use crate::query::conditions::WhereOperator::{ - Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, GreaterThan, - GreaterThanOrEquals, LessThan, LessThanOrEquals, - }; - use std::collections::BTreeMap; - - let mut by_field: BTreeMap> = BTreeMap::new(); - let mut non_range: Vec = Vec::new(); - for wc in clauses { - if DriveDocumentCountQuery::is_range_operator(wc.operator) { - by_field.entry(wc.field.clone()).or_default().push(wc); - } else { - non_range.push(wc); - } - } - let mut result = non_range; - for (field, mut ranges) in by_field { - match ranges.len() { - 0 => {} - 1 => result.push(ranges.remove(0)), - 2 => { - let (mut lower, mut upper): (Option, Option) = - (None, None); - for r in ranges { - match r.operator { - GreaterThan | GreaterThanOrEquals => { - if lower.is_some() { - return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "two lower-bound range clauses on the same field cannot be \ - merged; combine via `between*` or remove the redundant clause", - ))); - } - lower = Some(r); - } - LessThan | LessThanOrEquals => { - if upper.is_some() { - return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "two upper-bound range clauses on the same field cannot be \ - merged; combine via `between*` or remove the redundant clause", - ))); - } - upper = Some(r); - } - _ => { - // The other range operators (Between*, - // StartsWith) are themselves bounded - // already; a second range clause on the - // same field is structurally redundant. - return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "cannot pair a `between*`/`startsWith` range clause with \ - another range on the same field; use the pre-merged form", - ))); - } - } - } - let lower = lower.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "two range clauses on the same field require one lower bound (> or >=) \ - and one upper bound (< or <=)", - )))?; - let upper = upper.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "two range clauses on the same field require one lower bound (> or >=) \ - and one upper bound (< or <=)", - )))?; - let merged_op = match ( - lower.operator == GreaterThanOrEquals, - upper.operator == LessThanOrEquals, - ) { - (true, true) => Between, // [a, b] - (false, false) => BetweenExcludeBounds, // (a, b) - (true, false) => BetweenExcludeRight, // [a, b) - (false, true) => BetweenExcludeLeft, // (a, b] - }; - result.push(WhereClause { - field, - operator: merged_op, - value: dpp::platform_value::Value::Array(vec![lower.value, upper.value]), - }); - } - _ => { - return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses( - "more than two range clauses on the same field are not supported; a \ - bounded range needs exactly one lower bound and one upper bound", - ))); - } - } - } - Ok(result) -} - /// Parse the decoded `order_by` value into structured [`OrderClause`]s. /// /// Same shape as [`where_clauses_from_value`] for `order_by`: @@ -453,10 +286,7 @@ impl Drive { let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; let resolved_time_ranges = request.resolved_time_ranges; - crate::query::validate_resolved_time_range_clause_shapes( - &where_clauses, - &resolved_time_ranges, - )?; + validate_resolved_time_range_clause_shapes(&where_clauses, &resolved_time_ranges)?; let order_clauses = request.order_clauses; // Split-mode entry direction is whatever the first orderBy diff --git a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs index 06109a156da..9c3f4a773a1 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/index_picker.rs @@ -207,14 +207,26 @@ impl DriveDocumentCountQuery<'_> { // properties. For the widget contract there are no // such middle properties on byBrandColor, but the // builder handles the general case. + let intermediate_props = &index.properties[1..index.properties.len() - 1]; let mut intermediate_props_ok = true; - for prop in &index.properties[1..index.properties.len() - 1] { + for prop in intermediate_props { if !prefix_fields.contains(prop.name.as_str()) { intermediate_props_ok = false; break; } } - if intermediate_props_ok { + // Strict-coverage check, mirroring sum's picker: every + // Equal/In prefix field must appear in the index's + // intermediate properties. Without this + // `intermediate_props.len() == prefix_fields.len()` guard, + // a query with extra prefix fields would silently pick an + // index that *doesn't* cover them — the carrier path-query + // builder iterates only index properties, so the uncovered + // clause would simply be dropped and the per-group counts + // would span all its values (an over-broad result that + // even verifies, since the verifier rebuilds the same + // path query from the same picker). + if intermediate_props_ok && intermediate_props.len() == prefix_fields.len() { return Some(index); } continue; diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index 845d1f21fed..fbeadef5d80 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::drive::Drive; +use crate::query::ResolvedTimeRange; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -2263,6 +2264,89 @@ mod range_countable_picker_tests { ); } + /// Carrier arm (two ranges on distinct fields): an extra Equal clause + /// on a field the index does not carry must disqualify the index — + /// the carrier path-query builder iterates only index properties, so + /// an admitted index would silently drop the clause and produce an + /// over-broad per-group count that still verifies (the verifier + /// rebuilds the same path query from the same picker). Mirrors sum's + /// strict-coverage guard. + #[test] + fn carrier_arm_rejects_index_missing_an_equality_field() { + let indexes = make_indexes(vec![make_index( + "byBrandColor", + &["brand", "color"], + IndexCountability::Countable, + true, + )]); + let where_clauses = vec![ + WhereClause { + field: "brand".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("a".to_string()), + }, + WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("f".to_string()), + }, + WhereClause { + field: "material".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("wood".to_string()), + }, + ]; + assert!( + DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[], + ) + .is_none(), + "an equality clause the index cannot cover must disqualify it" + ); + } + + /// Positive control for the strict-coverage guard: the identical + /// query shape against an index that carries the equality field as + /// its intermediate property is covered and picked. + #[test] + fn carrier_arm_picks_index_covering_the_equality_field() { + let indexes = make_indexes(vec![make_index( + "byBrandMaterialColor", + &["brand", "material", "color"], + IndexCountability::Countable, + true, + )]); + let where_clauses = vec![ + WhereClause { + field: "brand".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("a".to_string()), + }, + WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("f".to_string()), + }, + WhereClause { + field: "material".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("wood".to_string()), + }, + ]; + let picked = DriveDocumentCountQuery::find_range_countable_index_for_where_clauses( + &indexes, + &where_clauses, + &[], + ); + assert_eq!( + picked.map(|index| index.name.as_str()), + Some("byBrandMaterialColor"), + "with the equality field covered, the carrier index is picked" + ); + } + /// An index without `range_countable: true` must not match even if /// the property structure aligns. The storage layout for these is /// plain NormalTree — no CountTree counts to walk. @@ -3486,8 +3570,8 @@ mod time_range_picker_tests { /// `trending` grid produces: the source field plus the exact transform. /// Constructed directly (not read from the candidate map) so tests that /// remove the trending index can still present the resolution. - fn source_resolution() -> Vec { - vec![crate::query::ResolvedTimeRange { + fn source_resolution() -> Vec { + vec![ResolvedTimeRange { field: SOURCE.to_string(), transform: TimeRangeTransform { source: SOURCE.to_string(), diff --git a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs index fd6f11d52fc..da3cf1652ef 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs @@ -10,13 +10,18 @@ //! CBOR-decoded `Value::Array` input into structured `Vec` / //! `Vec`. Identical input contract to count. +use crate::config::DriveConfig; use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::drive_document_sum_query::{ DocumentSumMode, DocumentSumRequest, DocumentSumResponse, RangeSumOptions, RangeSumWalkMode, SumMode, }; -use crate::query::{OrderClause, WhereClause}; +use crate::query::{ + validate_and_canonicalize_where_clauses, validate_resolved_time_range_clause_shapes, + OrderClause, WhereClause, +}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::platform_value::Value; @@ -25,18 +30,16 @@ use grovedb::TransactionArg; fn effective_no_proof_distinct_limit( requested_limit: Option, - drive_config: &crate::config::DriveConfig, + drive_config: &DriveConfig, ) -> Result { let effective_limit = requested_limit .unwrap_or(drive_config.default_query_limit as u32) .min(drive_config.max_query_limit as u32); if effective_limit == 0 { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::InvalidLimit( - "effective distinct SUM limit must be greater than zero".to_string(), - ), - )); + return Err(Error::Query(QuerySyntaxError::InvalidLimit( + "effective distinct SUM limit must be greater than zero".to_string(), + ))); } // Both configuration limits are u16, and the `min` above bounds every @@ -53,23 +56,28 @@ impl Drive { /// Mirrors [`Drive::execute_document_count_request`]. pub fn execute_document_sum_request( &self, - request: DocumentSumRequest, + mut request: DocumentSumRequest, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // Canonicalize exactly as the count and joint dispatchers do (the + // shared step in [`crate::query::canonicalize`]), so callers can + // pass either the bounded pair form (`[f > A, f < B]`) or the + // pre-merged `between*` form and get equivalent mode detection. + request.where_clauses = + validate_and_canonicalize_where_clauses(request.where_clauses, platform_version)?; + // Same provenance-vs-shape contract as the count and joint + // dispatchers, anchored before mode detection just as there. + validate_resolved_time_range_clause_shapes( + &request.where_clauses, + &request.resolved_time_ranges, + )?; let resolved_mode = super::mode_detection::detect_sum_mode(&request, platform_version)?; let contract_id = request.contract.id().to_buffer(); let document_type_name = request.document_type.name().to_string(); let where_clauses = request.where_clauses; let resolved_time_ranges = request.resolved_time_ranges; - // Same provenance-vs-shape contract as the count and joint - // dispatchers; sum has no canonicalize step, so the guard anchors - // here. - crate::query::validate_resolved_time_range_clause_shapes( - &where_clauses, - &resolved_time_ranges, - )?; let sum_property = request.sum_property; // Default direction is ascending; the first order clause's // direction (if any) wins. Mirrors count's analog. diff --git a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs index 03b55e21329..50a544cbdde 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/mod.rs @@ -53,8 +53,9 @@ pub mod executors; #[cfg(test)] mod tests; -#[cfg(any(feature = "server", feature = "verify"))] +#[cfg(feature = "server")] use crate::query::ResolvedTimeRange; +#[cfg(any(feature = "server", feature = "verify"))] use crate::query::{WhereClause, WhereOperator}; #[cfg(any(feature = "server", feature = "verify"))] diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 673c9ce0331..3ba4b10a2ca 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -143,6 +143,10 @@ use crate::util::grove_operations::QueryType::StatefulQuery; // Module declarations that are conditional on either "server" or "verify" features #[cfg(any(feature = "server", feature = "verify"))] +pub mod canonicalize; +#[cfg(any(feature = "server", feature = "verify"))] +pub use canonicalize::validate_and_canonicalize_where_clauses; +#[cfg(any(feature = "server", feature = "verify"))] pub mod conditions; #[cfg(any(feature = "server", feature = "verify"))] mod defaults; @@ -1937,43 +1941,12 @@ impl<'a> DriveDocumentQuery<'a> { )))); } + // One shared source-shape guard for every selection route — see its + // doc for the contract. Running it before routing keeps the single- + // and multiple-`In` routes rejecting the same shapes. + self.validate_resolved_source_shape()?; + if self.internal_clauses.in_clauses.len() > 1 { - // The multiple-`In` selection filters its candidates through the - // same admissibility rule, but it returns early and so bypasses - // the residual source-shape guard at the bottom of this function. - // Enforce the resolved-field contract here instead: the resolved - // equality must be present, and the bucketed source must not also - // carry an `In`, a range, or an ordering. - if let Some(source) = self - .resolved_time_ranges - .first() - .map(|resolved| &resolved.field) - { - let has_equality_on_source = - self.internal_clauses.equal_clauses.contains_key(source); - let in_or_range_on_source = self - .internal_clauses - .in_clauses - .iter() - .any(|clause| &clause.field == source) - || self - .internal_clauses - .range_clause - .as_ref() - .is_some_and(|clause| &clause.field == source); - if !has_equality_on_source - || in_or_range_on_source - || self.order_by.contains_key(source) - { - return Err(Error::Query(QuerySyntaxError::Unsupported(format!( - "the index on \"{}\" buckets it into time ranges: it can only be \ - queried through a time-range selection (IN_TIME_RANGE, which resolves \ - to an exact bucket equality), not with ranges, IN, or ordering on \ - that property", - source - )))); - } - } return Ok(self.find_best_index_for_multiple_in_clauses()?.0); } @@ -2039,10 +2012,32 @@ impl<'a> DriveDocumentQuery<'a> { self.document_type.indexes() ))) } - None => Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( - "query must be for valid indexes, valid indexes are: {:?}", - self.document_type.indexes() - ))), + None => { + // A raw query never binds to a bucketed index; when one + // exists, say so — the caller may be holding a + // time-range proof on a surface that cannot supply + // resolution provenance (e.g. the standalone wasm + // verifiers), where this refusal is otherwise opaque. + let has_bucketed_index = self + .document_type + .indexes() + .values() + .any(|index| index.time_range.is_some()); + if has_bucketed_index { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( + "query must be for valid indexes, valid indexes are: {:?}; note: \ + this document type's time-range (timeRange) indexes only serve \ + IN_TIME_RANGE selections carrying their resolution — a raw clause \ + on the bucketed field never binds to them", + self.document_type.indexes() + ))) + } else { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!( + "query must be for valid indexes, valid indexes are: {:?}", + self.document_type.indexes() + ))) + } + } })?; if difference > defaults::MAX_INDEX_DIFFERENCE { return Err(Error::Query(QuerySyntaxError::QueryTooFarFromIndex( @@ -2050,44 +2045,57 @@ impl<'a> DriveDocumentQuery<'a> { ))); } - // Candidate filtering already guarantees a transform-carrying index is - // only reachable when the equality on its source came from - // IN_TIME_RANGE resolution. What it cannot rule out is a query that - // carries that resolved equality AND some other shape on the same - // source — a range, an IN, or an ordering — riding along: those walk - // overlapping bucket keys and return each document up to - // `overlap_factor` times with a perfectly valid proof. Reject them - // here rather than serve a provably wrong answer. The - // `!has_equality_on_source` arm is defensive: resolution always - // pushes the equality, so reaching it means the provenance and the - // clauses disagree. This runs identically on the server and in proof - // verification. - if let Some(transform) = &index.time_range { - let source = transform.source.as_str(); - let has_equality_on_source = self.internal_clauses.equal_clauses.contains_key(source); - let range_or_in_on_source = self + // The residual source-shape contract already ran at the top of this + // function ([`Self::validate_resolved_source_shape`]) — with a + // resolution present, admissibility restricts candidates to the one + // index bucketing exactly the resolved field, so guarding by + // provenance there is equivalent to guarding by the selected index's + // transform here. + Ok(index) + } + + /// The residual source-shape contract for a query carrying a + /// time-range resolution: the resolved equality must be present on the + /// bucketed source, and the source must not ALSO carry an `In`, a + /// range, or an ordering — those walk overlapping bucket keys and + /// return each document up to `overlap_factor` times with a perfectly + /// valid proof. The `!has_equality_on_source` arm is defensive: + /// resolution always pushes the equality, so reaching it means the + /// provenance and the clauses disagree. + /// + /// Runs identically on the server and in proof verification, and on + /// every selection route: [`Self::find_best_index`] calls it before + /// routing, and [`Self::find_best_index_for_multiple_in_clauses`] + /// calls it itself because the multiple-`In` execution lowering picks + /// its index directly, without going through `find_best_index`. + #[cfg(any(feature = "server", feature = "verify"))] + pub(crate) fn validate_resolved_source_shape(&self) -> Result<(), Error> { + let Some(source) = self + .resolved_time_ranges + .first() + .map(|resolved| resolved.field.as_str()) + else { + return Ok(()); + }; + let has_equality_on_source = self.internal_clauses.equal_clauses.contains_key(source); + let range_or_in_on_source = self + .internal_clauses + .range_clause + .as_ref() + .is_some_and(|clause| clause.field == source) + || self .internal_clauses - .range_clause - .as_ref() - .map(|c| c.field == source) - .unwrap_or(false) - || self - .internal_clauses - .in_clauses - .iter() - .any(|c| c.field == source); - let orders_on_source = self.order_by.contains_key(source); - if !has_equality_on_source || range_or_in_on_source || orders_on_source { - return Err(Error::Query(QuerySyntaxError::Unsupported(format!( - "the index on \"{}\" buckets it into time ranges: it can only be queried \ - through a time-range selection (IN_TIME_RANGE, which resolves to an exact \ - bucket equality), not with ranges, IN, or ordering on that property", - source - )))); - } + .in_clauses + .iter() + .any(|clause| clause.field == source); + if !has_equality_on_source || range_or_in_on_source || self.order_by.contains_key(source) { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "the index on \"{source}\" buckets it into time ranges: it can only be queried \ + through a time-range selection (IN_TIME_RANGE, which resolves to an exact \ + bucket equality), not with ranges, IN, or ordering on that property" + )))); } - - Ok(index) + Ok(()) } #[cfg(any(feature = "server", feature = "verify"))] diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs index f68efb07c47..11e13e9d1a2 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/v0/mod.rs @@ -11,7 +11,7 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::conditions::WhereClause; use crate::query::ordering::OrderClause; -use crate::query::{defaults, DriveDocumentQuery}; +use crate::query::{defaults, index_admissible_for_resolved_time_range, DriveDocumentQuery}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{Index, IndexProperty}; @@ -267,6 +267,13 @@ impl<'a> DriveDocumentQuery<'a> { pub(in crate::query) fn find_best_index_for_multiple_in_clauses( &self, ) -> Result<(&Index, Vec<&WhereClause>, usize), Error> { + // The execution lowering reaches this selection directly, without + // going through `find_best_index` — enforce the resolved-source + // shape contract here too, so a direct caller cannot ride an `In` + // or range on the bucketed source into per-value bucket keys. + // (`find_best_index` also runs this; the re-run is cheap.) + self.validate_resolved_source_shape()?; + let equal_clauses = &self.internal_clauses.equal_clauses; let in_clauses = &self.internal_clauses.in_clauses; let range_field = self @@ -303,10 +310,7 @@ impl<'a> DriveDocumentQuery<'a> { // `find_best_index`: a bucketed index only for a query whose // resolved equality names its transform source, never for a raw // query. See `index_admissible_for_resolved_time_range`. - if !crate::query::index_admissible_for_resolved_time_range( - index, - &self.resolved_time_ranges, - ) { + if !index_admissible_for_resolved_time_range(index, &self.resolved_time_ranges) { continue; } let mut positioned: Vec<(usize, &WhereClause)> = Vec::with_capacity(in_clauses.len()); diff --git a/packages/wasm-drive-verify/src/document/verify_proof.rs b/packages/wasm-drive-verify/src/document/verify_proof.rs index 4aae3b52463..1cd189c9f3e 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof.rs @@ -108,6 +108,12 @@ pub fn verify_document_proof( start_at: start_at_bytes, start_at_included, block_time_ms, + // KNOWN LIMITATION: this surface has no input for time-range + // (IN_TIME_RANGE) resolution provenance, so proofs the server + // produced for a time-range query cannot be verified here — with + // empty provenance every bucketed index is inadmissible and + // verification fails closed. Use the SDK's FromProof path (which + // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], }; diff --git a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs index c04663d37b0..53e73ac22ab 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs @@ -99,6 +99,12 @@ pub fn verify_document_proof_keep_serialized( start_at: start_at_bytes, start_at_included, block_time_ms, + // KNOWN LIMITATION: this surface has no input for time-range + // (IN_TIME_RANGE) resolution provenance, so proofs the server + // produced for a time-range query cannot be verified here — with + // empty provenance every bucketed index is inadmissible and + // verification fails closed. Use the SDK's FromProof path (which + // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], }; diff --git a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs index de1ff84becd..12559c18701 100644 --- a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs @@ -107,6 +107,12 @@ pub fn verify_start_at_document_in_proof( start_at: start_at_bytes, start_at_included, block_time_ms, + // KNOWN LIMITATION: this surface has no input for time-range + // (IN_TIME_RANGE) resolution provenance, so proofs the server + // produced for a time-range query cannot be verified here — with + // empty provenance every bucketed index is inadmissible and + // verification fails closed. Use the SDK's FromProof path (which + // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], }; diff --git a/packages/wasm-sdk/src/queries/document.rs b/packages/wasm-sdk/src/queries/document.rs index 95b39c4d10c..abc3b36fe82 100644 --- a/packages/wasm-sdk/src/queries/document.rs +++ b/packages/wasm-sdk/src/queries/document.rs @@ -125,7 +125,8 @@ export interface DocumentsQuery { * picks a single bucket of a timestamp field covered by a `timeRange` * index. The server resolves the bucket from the current block time and * the proof verifier re-derives it from the signed response metadata, so - * the result is provable. v1 / Platform v3.1+ only. + * the result is provable. Requires protocol version 14+ (the first + * version whose contract grammar hosts `timeRange` indexes). * * - `selector: "oldest"` → the oldest still-active range (a near-full * trailing window of ~`range`; best for "trending over the last window"). From c8045af23012f0a49b578d2eab64ee22afc17d18 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 16:35:05 +0200 Subject: [PATCH 10/20] test(suite): tolerate the protocol v14 $contractVersion stamp in document comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive stamps $contractVersion on stored documents from protocol v14 (the requiredSince serialization format), so a fetched document carries it while a locally created one never does — every created-vs-fetched deep-equal in the suite fails on the stamp alone. Strip it in the comparison helpers exactly like the other server-assigned metadata. The regression came in with the requiredSince merge but only PR runs execute the functional suite, so the dev-branch push never surfaced it. Co-Authored-By: Claude Fable 5 --- .../test/e2e/contacts.spec.js | 26 ++++++++++++++++--- .../platform-test-suite/test/e2e/dpns.spec.js | 6 +++++ .../test/functional/platform/Document.spec.js | 4 +++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/platform-test-suite/test/e2e/contacts.spec.js b/packages/platform-test-suite/test/e2e/contacts.spec.js index 664b927495d..4689961a5d4 100644 --- a/packages/platform-test-suite/test/e2e/contacts.spec.js +++ b/packages/platform-test-suite/test/e2e/contacts.spec.js @@ -157,7 +157,11 @@ describe('e2e', () => { { where: [['$id', '==', profile.getId()]] }, ); - expect(fetchedProfile.toObject()).to.be.deep.equal(profile.toObject()); + const fetchedProfileObject = fetchedProfile.toObject(); + // Drive stamps the contract version on stored documents (protocol v14+) + delete fetchedProfileObject.$contractVersion; + + expect(fetchedProfileObject).to.be.deep.equal(profile.toObject()); }); it('should add encryption and decryption keys to the identity', async () => { @@ -256,7 +260,11 @@ describe('e2e', () => { { where: [['$id', '==', aliceProfile.getId()]] }, ); - expect(fetchedProfile.toObject()).to.be.deep.equal(aliceProfile.toObject()); + const fetchedProfileObject = fetchedProfile.toObject(); + // Drive stamps the contract version on stored documents (protocol v14+) + delete fetchedProfileObject.$contractVersion; + + expect(fetchedProfileObject).to.be.deep.equal(aliceProfile.toObject()); }); it('should be able to update her profile', async () => { @@ -279,6 +287,8 @@ describe('e2e', () => { const fetchedProfileObject = fetchedProfile.toObject(); delete fetchedProfileObject.$updatedAt; + // Drive stamps the contract version on stored documents (protocol v14+) + delete fetchedProfileObject.$contractVersion; const aliceObject = aliceProfile.toObject(); delete aliceObject.$updatedAt; @@ -311,7 +321,11 @@ describe('e2e', () => { { where: [['$id', '==', bobContactRequest.getId()]] }, ); - expect(fetchedContactRequest.toObject()).to.be.deep.equal(bobContactRequest.toObject()); + const fetchedContactRequestObject = fetchedContactRequest.toObject(); + // Drive stamps the contract version on stored documents (protocol v14+) + delete fetchedContactRequestObject.$contractVersion; + + expect(fetchedContactRequestObject).to.be.deep.equal(bobContactRequest.toObject()); }); }); @@ -336,7 +350,11 @@ describe('e2e', () => { { where: [['$id', '==', aliceContactAcceptance.getId()]] }, ); - expect(fetchedAliceContactAcceptance.toObject()).to.be.deep.equal( + const fetchedAcceptanceObject = fetchedAliceContactAcceptance.toObject(); + // Drive stamps the contract version on stored documents (protocol v14+) + delete fetchedAcceptanceObject.$contractVersion; + + expect(fetchedAcceptanceObject).to.be.deep.equal( aliceContactAcceptance.toObject(), ); }); diff --git a/packages/platform-test-suite/test/e2e/dpns.spec.js b/packages/platform-test-suite/test/e2e/dpns.spec.js index b9c6444682a..4fb56dd7e81 100644 --- a/packages/platform-test-suite/test/e2e/dpns.spec.js +++ b/packages/platform-test-suite/test/e2e/dpns.spec.js @@ -153,6 +153,7 @@ describe('DPNS', () => { delete rawDocument.$transferredAtCoreBlockHeight; delete rawDocument.$transferredAtBlockHeight; delete rawDocument.$creatorId; + delete rawDocument.$contractVersion; delete rawDocument.preorderSalt; const rawRegisteredDomain = registeredDomain.toObject(); @@ -167,6 +168,7 @@ describe('DPNS', () => { delete rawRegisteredDomain.$transferredAtCoreBlockHeight; delete rawRegisteredDomain.$transferredAtBlockHeight; delete rawRegisteredDomain.$creatorId; + delete rawRegisteredDomain.$contractVersion; delete rawRegisteredDomain.preorderSalt; expect(rawDocument).to.deep.equal(rawRegisteredDomain); @@ -187,6 +189,7 @@ describe('DPNS', () => { delete rawDocument.$transferredAtCoreBlockHeight; delete rawDocument.$transferredAtBlockHeight; delete rawDocument.$creatorId; + delete rawDocument.$contractVersion; delete rawDocument.preorderSalt; const rawRegisteredDomain = registeredDomain.toObject(); @@ -201,6 +204,7 @@ describe('DPNS', () => { delete rawRegisteredDomain.$transferredAtCoreBlockHeight; delete rawRegisteredDomain.$transferredAtBlockHeight; delete rawRegisteredDomain.$creatorId; + delete rawRegisteredDomain.$contractVersion; delete rawRegisteredDomain.preorderSalt; expect(rawDocument).to.deep.equal(rawRegisteredDomain); @@ -224,6 +228,7 @@ describe('DPNS', () => { delete rawDocument.$transferredAtCoreBlockHeight; delete rawDocument.$transferredAtBlockHeight; delete rawDocument.$creatorId; + delete rawDocument.$contractVersion; delete rawDocument.preorderSalt; const rawRegisteredDomain = registeredDomain.toObject(); @@ -238,6 +243,7 @@ describe('DPNS', () => { delete rawRegisteredDomain.$transferredAtCoreBlockHeight; delete rawRegisteredDomain.$transferredAtBlockHeight; delete rawRegisteredDomain.$creatorId; + delete rawRegisteredDomain.$contractVersion; delete rawRegisteredDomain.preorderSalt; expect(rawDocument).to.deep.equal(rawRegisteredDomain); diff --git a/packages/platform-test-suite/test/functional/platform/Document.spec.js b/packages/platform-test-suite/test/functional/platform/Document.spec.js index f30c7b4feb4..4c6f31ccbdd 100644 --- a/packages/platform-test-suite/test/functional/platform/Document.spec.js +++ b/packages/platform-test-suite/test/functional/platform/Document.spec.js @@ -25,6 +25,10 @@ const getDocumentObject = (document) => { delete documentObject.$createdAt; delete documentObject.$updatedAt; + // Drive stamps the contract version on stored documents (protocol v14+), + // so a fetched document carries it while a locally created one does not + delete documentObject.$contractVersion; + return documentObject; }; From 3c1dfc9efeb19ed53a478779b7721541b8fa6cdc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 16:42:52 +0200 Subject: [PATCH 11/20] test(sdk): pin the protocol gate's typed error variant The v13 refusal test only inspected Display text, so it would pass on any error mentioning protocol version 14 and break on a harmless rewording. Match Error::Config first, then check the version-floor message on its payload. Co-Authored-By: Claude Fable 5 --- .../dash-platform-queries/src/documents/document_query.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 4c727b6e625..cd710ddf40e 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -1437,9 +1437,13 @@ mod encode_version_gate_tests { platform_version, ) .expect_err("protocol version 13's contract grammar has no timeRange indexes"); + let message = match error { + Error::Config(message) => message, + other => panic!("expected Error::Config, got {other:?}"), + }; assert!( - error.to_string().contains("protocol version 14"), - "the refusal must name the real version floor, got: {error}" + message.contains("protocol version 14"), + "the refusal must name the real version floor, got: {message}" ); } From 4b1d34820d283fb15e5ea44c1ef64903a4424e5b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 18:12:22 +0200 Subject: [PATCH 12/20] fix(drive)!: apply in-bucket cursors at the document-id terminal, not the bucket-keyed level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A time-range index's transformed first level stores bucket starts, so when the resolved bucket equality is the query's last clause with no left-over index properties (a single-property time-range index), the cursor document's raw timestamp was compared against the bucket-start key: an included cursor created mid-bucket orders after the key, suppresses it, and validly proves an empty page while later document ids still exist in the bucket — proof reconstruction repeats the same lowering, so verification accepts it. Withhold the cursor from the bucket-keyed level and apply it by bucket membership instead: a cursor inside the selected bucket continues the document-id walk (for a unique index the bucket holds exactly the cursor document, so excluded means the page is exhausted); a cursor from outside the bucket cannot order within it and serves the full bucket. Regression: three posts in one bucket, an included and an excluded mid-bucket cursor return the id-ordered continuation instead of an empty page. Co-Authored-By: Claude Fable 5 --- .../insert/add_document_for_contract/mod.rs | 139 ++++++++++++++++-- .../single_in_path_query/v0/mod.rs | 84 ++++++++++- 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index d3e95911e69..d78617e9fba 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -217,10 +217,7 @@ mod time_range_index_e2e_tests { .values() .find_map(|index| index.time_range.clone()) .expect("the fixture declares a time-range index"); - vec![ResolvedTimeRange { - field: transform.source.clone(), - transform, - }] + vec![ResolvedTimeRange { transform }] } /// A `$createdAt == created_at` query, optionally ANDed with @@ -756,6 +753,134 @@ mod time_range_index_e2e_tests { ); } + /// A contract whose only index buckets `$createdAt` alone — the shape + /// where a resolved bucket equality is the query's *last* clause with no + /// left-over index properties, so cursor pagination has nothing below the + /// transformed level except the document-id terminal. + fn build_single_property_trending_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byBucket".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![platform_value!({"$createdAt": "asc"})]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ]), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = Identifier::from([202u8; 32]); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() + } + + /// The transformed level stores bucket starts, so a cursor document's raw + /// timestamp must never be compared against this level's keys: an + /// included cursor created *inside* the selected bucket (07:10 in a + /// bucket starting at 06:00) orders after the bucket-start key, and + /// applying it at the transformed level suppresses the only key — a + /// validly-proven empty page while later document ids still exist in the + /// bucket. The cursor belongs to the document-id terminal instead. + #[test] + fn included_cursor_inside_the_bucket_continues_a_single_property_time_range_query() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_single_property_trending_contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + let document_type = contract.document_type_for_name("post").expect("post"); + + // Three posts inside the [06:00, 12:00) bucket, none at its start. + let bucket = 6 * HOUR_MS; + let timestamps: [(u64, &str); 3] = [ + (6 * HOUR_MS + 600_000, "early"), + (7 * HOUR_MS + 123_456, "mid"), + (8 * HOUR_MS, "late"), + ]; + for (created_at, hashtag) in timestamps { + insert_post(&drive, &contract, created_at, hashtag, platform_version); + } + + // The document-id terminal walks ids ascending; the cursor is the + // mid-bucket post, so the expected continuation is every id at or + // after it in that order. + let mut ids: Vec<[u8; 32]> = timestamps + .iter() + .map(|(created_at, hashtag)| fixture_bytes(2, *created_at, hashtag)) + .collect(); + ids.sort(); + let cursor_id = fixture_bytes(2, 7 * HOUR_MS + 123_456, "mid"); + let cursor_position = ids.iter().position(|id| *id == cursor_id).expect("cursor"); + + let mut query = build_created_at_query( + &contract, + document_type, + bucket, + None, + created_at_resolution(document_type), + ); + query.start_at = Some(cursor_id); + query.start_at_included = true; + + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query with an in-bucket cursor"); + assert_eq!( + results.len(), + ids.len() - cursor_position, + "an included in-bucket cursor must return itself and every later id in the bucket" + ); + + query.start_at_included = false; + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query with an excluded in-bucket cursor"); + assert_eq!( + results.len(), + ids.len() - cursor_position - 1, + "an excluded in-bucket cursor must return every id after it in the bucket" + ); + } + /// One index can bucket only one field (a transform's source must be its /// index's first property), so a query resolving two time ranges has no /// servable shape and is refused rather than routed to whichever index @@ -767,7 +892,6 @@ mod time_range_index_e2e_tests { let query = build_created_at_query(&contract, document_type, 6 * HOUR_MS, Some("ibiza"), { let mut resolutions = created_at_resolution(document_type); let mut second = resolutions[0].clone(); - second.field = "hashtag".to_string(); second.transform.source = "hashtag".to_string(); resolutions.push(second); resolutions @@ -1209,10 +1333,7 @@ mod time_range_index_e2e_tests { .time_range .clone() .expect("the index carries a transform"); - vec![ResolvedTimeRange { - field: transform.source.clone(), - transform, - }] + vec![ResolvedTimeRange { transform }] } /// Two grids over `$createdAt`: a document fans out into each grid's own diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs index 2b6d106c62f..e7b4363da61 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/v0/mod.rs @@ -459,8 +459,24 @@ impl<'a> DriveDocumentQuery<'a> { // We should set the starts at document to be included for the query if there are // left over index properties. + // A time-range index's transformed first level stores bucket + // *starts*, so the cursor document's raw timestamp is not + // comparable to this level's keys: an included cursor created + // mid-bucket orders after the bucket-start key and would + // suppress it, validly proving an empty page. The resolved + // equality already pins this level to one key; the terminal + // document-id query attached below applies the cursor. + let last_clause_is_on_transformed_source = index + .time_range + .as_ref() + .is_some_and(|transform| transform.source == where_clause.field); + let query_starts_at_document = if left_over_index_properties.is_empty() { - &starts_at_document + if last_clause_is_on_transformed_source { + &None + } else { + &starts_at_document + } } else if sibling_aware_cursor_lowering { // The cursor's branch always stays included at this level, // trimming only the branches ordered before it; whether @@ -534,6 +550,72 @@ impl<'a> DriveDocumentQuery<'a> { &self.order_by, platform_version, )?; + } else if last_clause_is_on_transformed_source + && left_over_index_properties.is_empty() + { + // The cursor was deliberately withheld from the + // bucket-keyed level above; apply it here by + // bucket membership instead. A cursor inside the + // selected bucket continues the walk at its + // document id (for a unique index the bucket + // holds exactly the cursor document, so excluded + // means the page is exhausted); a cursor from + // outside the bucket cannot order within it and + // is ignored. + let cursor_in_bucket = match &starts_at_document { + None => None, + Some((document, included)) => { + let transform = index + .time_range + .as_ref() + .expect("checked by last_clause_is_on_transformed_source"); + let bucket_key = self.document_type.serialize_value_for_key( + where_clause.field.as_str(), + &where_clause.value, + platform_version, + )?; + document + .get_raw_for_document_type( + where_clause.field.as_str(), + self.document_type, + None, + platform_version, + )? + .filter(|raw| { + transform.entry_keys_for_raw(raw).contains(&bucket_key) + }) + .map(|_| (document, *included)) + } + }; + match cursor_in_bucket { + Some((document, included)) if !index.unique => { + query.set_subquery_key(vec![0]); + query.set_subquery(Self::inner_query_from_starts_at_for_id( + Some(&StartAtDocument { + document: document.clone(), + document_type: self.document_type, + included, + }), + left_to_right, + )); + } + cursor => { + if matches!(cursor, Some((_, false))) { + // Unique: the excluded cursor is the + // bucket's only document. + query = Query::new_with_direction(left_to_right); + } + Self::recursive_insert_on_query_ordered_with_cursor( + &mut query, + left_over_index_properties.as_slice(), + index.unique, + None, + left_to_right, + &self.order_by, + platform_version, + )?; + } + } } else { Self::recursive_insert_on_query_ordered_with_cursor( &mut query, From 77a31b3357dea8ab034b90e75939fa72e041b76a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 18:12:22 +0200 Subject: [PATCH 13/20] refactor(drive): derive time-range provenance's field from its transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolvedTimeRange stored the source field both directly and inside its transform, so a direct Rust caller could fabricate a pair naming a field the grid does not bucket, and every consumer had to guard the mismatch. The transform is now the single source of truth — field() is an accessor over transform.source — which makes inconsistent provenance unrepresentable, and the admissibility guard reduces to transform equality. The count picker's fabricated-mismatch test becomes a fabricated-grid test, the only inconsistency still expressible. Co-Authored-By: Claude Fable 5 --- .../src/documents/count_proof_helpers.rs | 2 +- .../validate_uniqueness_of_data/v1/mod.rs | 1 - .../drive_dispatcher.rs | 1 - .../query/drive_document_count_query/tests.rs | 21 ++++---- packages/rs-drive/src/query/mod.rs | 48 ++++++++++--------- 5 files changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index d7b28a8e7c1..cf060ecff31 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -614,7 +614,7 @@ mod tests { .expect("a metadata time inside an active range resolves"); assert_eq!(resolutions.len(), 1); - assert_eq!(resolutions[0].field, CREATED_AT); + assert_eq!(resolutions[0].field(), CREATED_AT); assert_eq!( resolutions[0].transform.range_seconds, RANGE_SECONDS, "the provenance must carry the exact grid the resolution used" diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs index cf802c3ca1c..2c3572be562 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs @@ -438,7 +438,6 @@ impl Drive { let bucket_start = *transform.containing_buckets(timestamp).first()?; clause.value = platform_value!(bucket_start); resolved_time_ranges.push(ResolvedTimeRange { - field: transform.source.clone(), transform: transform.clone(), }); } diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 339255b45b4..da9f8de2de6 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -2024,7 +2024,6 @@ mod tests { prove: true, drive_config: &drive_config, resolved_time_ranges: vec![ResolvedTimeRange { - field: "$createdAt".to_string(), transform: dpp::data_contract::document_type::TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 21_600, diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index fbeadef5d80..c8e6cc7986c 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -3572,7 +3572,6 @@ mod time_range_picker_tests { /// remove the trending index can still present the resolution. fn source_resolution() -> Vec { vec![ResolvedTimeRange { - field: SOURCE.to_string(), transform: TimeRangeTransform { source: SOURCE.to_string(), range_seconds: 6 * HOUR_SECONDS, @@ -3681,22 +3680,20 @@ mod time_range_picker_tests { ); } - /// Provenance is a `(field, transform)` pair and admissibility must bind - /// them together: a fabricated entry carrying the real transform under a - /// *different* field would let the shape guard validate the wrong clause - /// while a caller-supplied raw equality on the transform's source rode - /// into the bucketed index as if it were a resolved bucket start. The - /// resolver always produces `field == transform.source`; anything else - /// must be inadmissible. + /// Provenance names its field through the transform itself + /// ([`ResolvedTimeRange::field`] is derived from `transform.source`), so + /// the fabricated field/transform mismatch this test used to construct is + /// unrepresentable. What remains fabricatable is a resolution whose grid + /// no index declares — it must admit nothing. #[test] - fn provenance_with_a_field_not_matching_its_transform_source_admits_nothing() { + fn provenance_with_a_grid_no_index_declares_admits_nothing() { let indexes = indexes(); let where_clauses = vec![ equal(SOURCE, Value::U64(6 * HOUR_MS)), equal("hashtag", Value::Text("ibiza".to_string())), ]; let mut mismatched = source_resolution(); - mismatched[0].field = "hashtag".to_string(); + mismatched[0].transform.step_seconds /= 2; assert!( DriveDocumentCountQuery::find_countable_index_for_where_clauses( &indexes, @@ -3704,8 +3701,8 @@ mod time_range_picker_tests { &mismatched, ) .is_none(), - "a transform attached to a field other than its source must not \ - admit the bucketed index (nor, being a resolution, the plain one)" + "a resolution carrying a grid no index declares must not admit \ + the bucketed index (nor, being a resolution, the plain one)" ); } } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 3ba4b10a2ca..4907fb1eb64 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -657,12 +657,21 @@ impl TimeRangeGridSpec { #[cfg(any(feature = "server", feature = "verify"))] #[derive(Debug, Clone, PartialEq)] pub struct ResolvedTimeRange { - /// The bucketed source field the resolved equality is on. - pub field: String, - /// The grid the bucket start was computed from. + /// The grid the bucket start was computed from. The transform carries its + /// own source field, so the provenance cannot name a field the grid does + /// not bucket — [`Self::field`] reads it from here. pub transform: TimeRangeTransform, } +#[cfg(any(feature = "server", feature = "verify"))] +impl ResolvedTimeRange { + /// The bucketed source field the resolved equality is on — always the + /// transform's own source. + pub fn field(&self) -> &str { + &self.transform.source + } +} + /// Resolves a time-range selection on `field` into a concrete equality /// [`WhereClause`] on the bucketed source field, using the named grid's /// `timeRange` transform and an authoritative `block_time_ms`. @@ -755,7 +764,6 @@ pub fn resolve_time_range_bucket_clause( value: Value::U64(bucket_start), }, ResolvedTimeRange { - field: field.to_string(), transform: transform.clone(), }, )) @@ -794,19 +802,16 @@ pub fn index_admissible_for_resolved_time_range( ) -> bool { match resolved_time_ranges { [] => index.time_range.is_none(), - // Both halves of the provenance must agree with the candidate: the - // transform (which grid the bucket start was computed from) AND the - // field (which clause the shape guard validated). The two are - // independently settable by a direct Rust caller, and a mismatched - // pair — a real transform attached to some other field — would let - // the shape guard validate the wrong clause while a caller-supplied - // raw equality on the transform's source rode into the bucketed - // index as if it were a resolved bucket start. The resolver always - // produces `field == transform.source`; this makes fabricated - // provenance that doesn't inadmissible everywhere. - [resolved] => index.time_range.as_ref().is_some_and(|transform| { - transform.source == resolved.field && *transform == resolved.transform - }), + // The provenance's transform must equal the candidate's — grid AND + // source field, since the transform carries its own source. The + // provenance cannot name a field its grid does not bucket + // ([`ResolvedTimeRange::field`] is derived from the transform), so a + // fabricated field/transform pair is unrepresentable rather than + // guarded against. + [resolved] => index + .time_range + .as_ref() + .is_some_and(|transform| *transform == resolved.transform), _ => false, } } @@ -830,9 +835,9 @@ pub fn validate_resolved_time_range_clause_shapes( where_clauses: &[WhereClause], resolved_time_ranges: &[ResolvedTimeRange], ) -> Result<(), Error> { - for field in resolved_time_ranges.iter().map(|resolved| &resolved.field) { + for field in resolved_time_ranges.iter().map(|resolved| resolved.field()) { let mut equalities = 0usize; - for clause in where_clauses.iter().filter(|c| &c.field == field) { + for clause in where_clauses.iter().filter(|c| c.field == field) { if clause.operator == WhereOperator::Equal { equalities += 1; } else { @@ -2008,7 +2013,7 @@ impl<'a> DriveDocumentQuery<'a> { "a time-range query on \"{}\" requires an index that buckets it with \ the resolved grid AND covers the query's other where and order-by \ fields; valid indexes are: {:?}", - resolved.field, + resolved.field(), self.document_type.indexes() ))) } @@ -2073,7 +2078,7 @@ impl<'a> DriveDocumentQuery<'a> { let Some(source) = self .resolved_time_ranges .first() - .map(|resolved| resolved.field.as_str()) + .map(|resolved| resolved.field()) else { return Ok(()); }; @@ -3210,7 +3215,6 @@ mod tests { use dpp::data_contract::document_type::TimeRangeTransform; let resolved = vec![ResolvedTimeRange { - field: "$createdAt".to_string(), transform: TimeRangeTransform { source: "$createdAt".to_string(), range_seconds: 21_600, From 808c766d870a3de70edba4826d530cbe0ff2a760 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 18:12:23 +0200 Subject: [PATCH 14/20] refactor(dpp): keep the grammar-admission mapping crate-private IndexGrammarAdmissions is an internal generation-to-keywords mapping shared by the schema parsers and the registration-cost re-parse; every use is inside the dpp crate, so pub(crate) keeps the sharing without exposing parser internals as downstream API. Co-Authored-By: Claude Fable 5 --- .../rs-dpp/src/data_contract/document_type/index/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 85680304993..168081ce4cf 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -522,11 +522,11 @@ pub struct Index { /// and the registration-cost re-parse. Deriving the flags anywhere else /// invites the two to drift, and then an index the validator parses is /// billed nothing (or a rejected one is billed). -pub struct IndexGrammarAdmissions { +pub(crate) struct IndexGrammarAdmissions { /// The `ranked*` keyword family (generation 3 and later). - pub ranked: bool, + pub(crate) ranked: bool, /// The `timeRange` keyword (generation 3 and later). - pub time_range: bool, + pub(crate) time_range: bool, } impl IndexGrammarAdmissions { @@ -534,7 +534,7 @@ impl IndexGrammarAdmissions { /// flags currently move together; they stay separate fields because /// they are separate grammar admissions — a future generation may /// admit one without the other, and then only this mapping changes. - pub fn for_schema_generation(generation: u16) -> Self { + pub(crate) fn for_schema_generation(generation: u16) -> Self { Self { ranked: generation >= 3, time_range: generation >= 3, From dd061f96c9d4b2f2ea881e380347b1585f4cb5ca Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 26 Aug 2026 18:59:27 +0200 Subject: [PATCH 15/20] refactor(drive)!: route ranked and having-range proofs through grovedb's unified PathQuery surface (#4488) Co-authored-by: Claude Fable 5 --- Cargo.lock | 63 +++--- .../src/documents/ranked_proof_helpers.rs | 6 +- packages/rs-dpp/Cargo.toml | 2 +- packages/rs-drive-abci/Cargo.toml | 8 +- .../src/abci/handler/prepare_proposal.rs | 3 +- .../src/abci/handler/process_proposal.rs | 3 +- .../process_raw_state_transitions/v0/mod.rs | 3 +- .../tests/document/ranked_group_drain.rs | 27 ++- .../query/document_query/v1/dispatch/mod.rs | 9 +- packages/rs-drive/Cargo.toml | 13 +- .../v0/tests/ranked_index_e2e_tests.rs | 77 ++++--- .../execute_range.rs | 196 +++++++++--------- .../query/drive_document_having_query/mod.rs | 56 ++--- .../drive_document_having_query/tests.rs | 73 +++++-- .../execute_top_k.rs | 182 ++++++++-------- .../query/drive_document_ranked_query/mod.rs | 8 +- .../src/verify/document_having/mod.rs | 6 +- .../verify_having_range_proof/mod.rs | 4 +- .../verify_having_range_proof/v0/mod.rs | 93 +++++---- .../src/verify/document_ranked/mod.rs | 5 +- .../verify_ranked_top_k_proof/v0/mod.rs | 81 +++++--- packages/rs-platform-version/Cargo.toml | 2 +- .../drive_verify_method_versions/mod.rs | 4 +- packages/rs-platform-wallet/Cargo.toml | 2 +- packages/rs-sdk/Cargo.toml | 2 +- 25 files changed, 506 insertions(+), 422 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83c098169ca..2b169644ccf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,7 +576,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2195,6 +2195,7 @@ dependencies = [ "grovedb-costs", "grovedb-epoch-based-storage-flags", "grovedb-path", + "grovedb-query", "grovedb-storage", "grovedb-version", "hex", @@ -2494,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2974,7 +2975,7 @@ dependencies = [ [[package]] name = "grovedb" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "axum 0.8.9", "bincode", @@ -3013,7 +3014,7 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "blake3", @@ -3031,7 +3032,7 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "blake3", "grovedb-bulk-append-tree", @@ -3048,7 +3049,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "integer-encoding", "intmap", @@ -3058,7 +3059,7 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "blake3", @@ -3072,7 +3073,7 @@ dependencies = [ [[package]] name = "grovedb-element" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "bincode_derive", @@ -3088,7 +3089,7 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "grovedb-costs", "hex", @@ -3100,7 +3101,7 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "bincode_derive", @@ -3126,7 +3127,7 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "blake3", @@ -3139,7 +3140,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "hex", ] @@ -3147,7 +3148,7 @@ dependencies = [ [[package]] name = "grovedb-private-document-store" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "blake3", "grovedb-bulk-append-tree", @@ -3160,7 +3161,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "bincode", "byteorder", @@ -3176,7 +3177,7 @@ dependencies = [ [[package]] name = "grovedb-storage" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "blake3", "grovedb-costs", @@ -3195,7 +3196,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3204,7 +3205,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "hex", "itertools 0.14.0", @@ -3213,7 +3214,7 @@ dependencies = [ [[package]] name = "grovedbg-types" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=753a11f14c9a4bc72bf2d5302751dd43d174621e#753a11f14c9a4bc72bf2d5302751dd43d174621e" +source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" dependencies = [ "serde", "serde_with 3.21.0", @@ -3629,7 +3630,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3880,7 +3881,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4692,7 +4693,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5582,7 +5583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.4.1", - "itertools 0.13.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -5603,7 +5604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -5616,7 +5617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -5752,7 +5753,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5790,7 +5791,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.59.0", ] @@ -6613,7 +6614,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6672,7 +6673,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7532,7 +7533,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8981,7 +8982,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index 785b2f018e8..7ab5980bb60 100644 --- a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -13,9 +13,9 @@ //! two copies of a grammar agreeing. //! //! Unlike the count helper there is no per-shape dispatch: the ranked -//! surface has exactly one proof primitive -//! (`prove_indexed_axis_top_k_paginated`), and all of a request's -//! variation is carried *inside* the query struct. +//! surface has exactly one proof primitive (grovedb's unified +//! `prove_query` over `PathQuery::new_axis_top_k`), and all of a +//! request's variation is carried *inside* the query struct. //! //! [`DocumentRankedEntries`]: drive_proof_verifier::DocumentRankedEntries diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index 0759b188069..e72bbd77413 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -71,7 +71,7 @@ strum = { version = "0.26", features = ["derive"] } json-schema-compatibility-validator = { path = '../rs-json-schema-compatibility-validator', optional = true } once_cell = "1.19.0" tracing = { version = "0.1.41" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", optional = true } [dev-dependencies] tokio = { version = "1.40", features = ["full"] } diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 6bba018371c..f3e3fccbc69 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -82,7 +82,7 @@ derive_more = { version = "1.0", features = ["from", "deref", "deref_mut"] } async-trait = "0.1.77" console-subscriber = { version = "0.4", optional = true } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f", optional = true } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } nonempty = "0.11" # Shielded-pool snapshot needs raw RocksDB SstFileWriter + ingest_external_file_cf # bindings, and blake3 for the snapshot-file checksum. @@ -108,7 +108,7 @@ dpp = { path = "../rs-dpp", default-features = false, features = [ drive = { path = "../rs-drive", features = ["fixtures-and-mocks"] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } strategy-tests = { path = "../strategy-tests" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", features = ["client"] } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", features = ["client"] } assert_matches = "1.5.0" drive-abci = { path = ".", features = ["testing-config", "mocks", "shielded_test_data"] } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f" } @@ -122,8 +122,8 @@ integer-encoding = { version = "4.0.0" } # For dump_only_default_and_aux_cfs_under_shielded_subtree_prefix — same # subtree-prefix algorithm grovedb uses internally. -grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } -grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } +grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } +grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } [features] default = ["bls-signatures"] diff --git a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs index 10a4bd95631..e7191125946 100644 --- a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs @@ -13,7 +13,6 @@ use crate::rpc::core::CoreRPCLike; use dpp::dashcore::hashes::Hash; use dpp::dashcore::Network; use dpp::version::TryIntoPlatformVersioned; -use drive::grovedb_storage::Error::RocksDBError; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::tx_record::TxAction; use tenderdash_abci::proto::abci::{ExecTxResult, TxRecord}; @@ -142,7 +141,7 @@ where ); if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() - .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; + .map_err(|e| drive::grovedb::error::Error::StorageError(e))?; tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index a64aba5013b..ce898a7dc77 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -14,7 +14,6 @@ use crate::platform_types::state_transitions_processing_result::StateTransitionE use crate::rpc::core::CoreRPCLike; use dpp::dashcore::Network; use dpp::version::TryIntoPlatformVersioned; -use drive::grovedb_storage::Error::RocksDBError; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::tx_record::TxAction; @@ -173,7 +172,7 @@ where ); if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() - .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; + .map_err(|e| drive::grovedb::error::Error::StorageError(e))?; tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 4e19577b925..2a17f38dba9 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -17,7 +17,6 @@ use crate::platform_types::state_transitions_processing_result::{ use dpp::util::hash::hash_single; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; -use drive::grovedb_storage::Error::RocksDBError; use std::time::Instant; use super::super::StateTransitionAwareError; @@ -224,7 +223,7 @@ where // failure means the proposal can no longer match the // block — fail it rather than continue on leaked state. transaction.rollback_to_savepoint().map_err(|e| { - drive::grovedb::error::Error::StorageError(RocksDBError(e)) + drive::grovedb::error::Error::StorageError(e) })?; } StateTransitionExecutionResult::SuccessfulExecution { .. } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/ranked_group_drain.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/ranked_group_drain.rs index 10ada3042f7..8e97e3826b1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/ranked_group_drain.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/ranked_group_drain.rs @@ -39,7 +39,10 @@ use dpp::state_transition::batch_transition::document_delete_transition::Documen use dpp::state_transition::batch_transition::BatchTransitionV1; use dpp::state_transition::StateTransition; use drive::drive::RootTree; -use drive::grovedb::Element; +use drive::grovedb::element::IndexAxis; +use drive::grovedb::operations::proof::indexed_axis::AxisEntries; +use drive::grovedb::query_result_type::QueryResultType; +use drive::grovedb::{Element, PathQuery, PathQueryRun}; /// The group that gets emptied. const G: &str = "beta"; @@ -75,19 +78,29 @@ fn ranked_count_groups( path: &[Vec], platform_version: &PlatformVersion, ) -> Vec<(u64, String)> { - let path_refs: Vec<&[u8]> = path.iter().map(|v| v.as_slice()).collect(); - platform + let path_query = PathQuery::new_axis_top_k(path.to_vec(), IndexAxis::Count, 100, 0, true); + let run = platform .drive .grove - .indexed_count_top_k( - path_refs.as_slice(), - 100, + .run_path_query( + &path_query, + true, + true, true, + QueryResultType::QueryPathKeyElementTrioResultType, None, &platform_version.drive.grove_version, ) .unwrap() - .expect("the ranked count read must succeed") + .expect("the ranked count read must succeed"); + let PathQueryRun::AxisEntries { + entries: AxisEntries::Count(entries), + .. + } = run + else { + panic!("expected count axis entries"); + }; + entries .into_iter() .map(|entry| entry.key_pair()) .map(|(count, key)| { diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs index 4c2d0315f48..df6b78461cf 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs @@ -58,10 +58,11 @@ fn into_v1_ranked_entry(e: DriveRankedEntry) -> RankedEntry { /// proved**. /// /// **This is now a backstop rather than a live path.** The ranked -/// prover moved to `prove_indexed_axis_top_k_paginated`, which emits a -/// guaranteed-empty range against an empty axis secondary instead of -/// refusing, so proving a ranking over a contract with no documents -/// succeeds and the proved and unproven paths agree (pinned by +/// prover moved to the paginated axis traversal (today through +/// grovedb's unified `prove_query`), which emits a guaranteed-empty +/// range against an empty axis secondary instead of refusing, so +/// proving a ranking over a contract with no documents succeeds and +/// the proved and unproven paths agree (pinned by /// `ranked_tests::proving_an_empty_ranking_succeeds`). The mapping is /// kept because the failure it recognizes is a *class* — a merk-level /// "cannot prove an empty tree" surfacing from somewhere in the diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index 54012ec1f1f..1f505ea1aa6 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -52,12 +52,13 @@ enum-map = { version = "2.0.3", optional = true } intmap = { version = "3.0.1", features = ["serde"], optional = true } chrono = { version = "0.4.35", optional = true } itertools = { version = "0.13", optional = true } -grovedb = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", optional = true, default-features = false } -grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", optional = true } -grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } -grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", optional = true } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } -grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } +grovedb = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", optional = true, default-features = false } +grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", optional = true } +grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } +grovedb-query = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } +grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", optional = true } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } +grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } [dev-dependencies] criterion = "0.5" diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs index 9262a07f598..d59306b7e26 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs @@ -72,7 +72,10 @@ use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_contract; use dpp::version::PlatformVersion; use grovedb::element::indexed::AVG_FIXED_POINT_SCALE; -use grovedb::Element; +use grovedb::element::IndexAxis; +use grovedb::query_result_type::QueryResultType; +use grovedb::{AxisKeys, Element, PathQuery, PathQueryRun}; +use grovedb_query::AxisQuery; /// The one index property every doctype in the fixture ranks by. const GROUP_PROPERTY: &str = "restaurantId"; @@ -271,49 +274,57 @@ fn group_keys(entries: &[(T, Vec)]) -> Vec { .collect() } -fn avg_top_k(drive: &Drive, path: &[Vec], k: u16, descending: bool) -> Vec<(i128, Vec)> { - let path_refs: Vec<&[u8]> = path.iter().map(|v| v.as_slice()).collect(); - drive +/// Run a keys-only top-k axis read through the unified PathQuery +/// surface — the same route the ranked no-proof executor takes. +fn run_top_k_keys( + drive: &Drive, + path: &[Vec], + axis: IndexAxis, + k: u16, + descending: bool, +) -> AxisKeys { + let path_query = PathQuery::new_axis( + path.to_vec(), + AxisQuery::top_k(axis, k, 0, descending).keys_only(), + ); + match drive .grove - .indexed_avg_top_k_keys( - path_refs.as_slice(), - k, - descending, + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryPathKeyElementTrioResultType, None, &platform_version().drive.grove_version, ) .unwrap() - .expect("indexed_avg_top_k_keys must succeed") + .expect("top-k axis read must succeed") + { + PathQueryRun::AxisKeys { keys, .. } => keys, + other => panic!("expected AxisKeys, got {other:?}"), + } +} + +fn avg_top_k(drive: &Drive, path: &[Vec], k: u16, descending: bool) -> Vec<(i128, Vec)> { + match run_top_k_keys(drive, path, IndexAxis::Avg, k, descending) { + AxisKeys::Avg(pairs) => pairs, + other => panic!("expected avg pairs, got {other:?}"), + } } fn count_top_k(drive: &Drive, path: &[Vec], k: u16, descending: bool) -> Vec<(u64, Vec)> { - let path_refs: Vec<&[u8]> = path.iter().map(|v| v.as_slice()).collect(); - drive - .grove - .indexed_count_top_k_keys( - path_refs.as_slice(), - k, - descending, - None, - &platform_version().drive.grove_version, - ) - .unwrap() - .expect("indexed_count_top_k_keys must succeed") + match run_top_k_keys(drive, path, IndexAxis::Count, k, descending) { + AxisKeys::Count(pairs) => pairs, + other => panic!("expected count pairs, got {other:?}"), + } } fn sum_top_k(drive: &Drive, path: &[Vec], k: u16, descending: bool) -> Vec<(i64, Vec)> { - let path_refs: Vec<&[u8]> = path.iter().map(|v| v.as_slice()).collect(); - drive - .grove - .indexed_sum_top_k_keys( - path_refs.as_slice(), - k, - descending, - None, - &platform_version().drive.grove_version, - ) - .unwrap() - .expect("indexed_sum_top_k_keys must succeed") + match run_top_k_keys(drive, path, IndexAxis::Sum, k, descending) { + AxisKeys::Sum(pairs) => pairs, + other => panic!("expected sum pairs, got {other:?}"), + } } // --------------------------------------------------------------------------- diff --git a/packages/rs-drive/src/query/drive_document_having_query/execute_range.rs b/packages/rs-drive/src/query/drive_document_having_query/execute_range.rs index 6707461d8d5..fa02f06804a 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/execute_range.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/execute_range.rs @@ -12,13 +12,15 @@ //! `pub mod execute_range;` declaration. use super::super::drive_document_ranked_query::{RankedAxis, RankedEntry, RankedEntryValue}; -use super::{AxisRangeBounds, DriveDocumentHavingQuery}; +use super::DriveDocumentHavingQuery; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use dpp::version::PlatformVersion; -use grovedb::TransactionArg; +use grovedb::query_result_type::QueryResultType; +use grovedb::{AxisKeys, PathQuery, PathQueryRun, TransactionArg}; use grovedb_costs::CostContext; +use grovedb_query::AxisQuery; impl DriveDocumentHavingQuery<'_> { /// Read the matching groups directly from the axis secondary: every @@ -41,68 +43,73 @@ impl DriveDocumentHavingQuery<'_> { ) -> Result, Error> { let grove_version = &platform_version.drive.grove_version; let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); + + // The same bounded axis PathQuery the prove path uses, with the + // keys-only projection: the matching pairs are read straight off + // the pinned secondary view, no primary values resolved. + let (lo, hi) = self.bounds.i128_bounds(); + let path_query = PathQuery::new_axis( + path, + AxisQuery::bounded( + self.bounds.axis().into(), + lo, + hi, + self.limit, + self.descending, + ) + .keys_only(), + ); // Costs are destructured away rather than `.unwrap()`-ed, same // as the ranked executors: `CostContext::unwrap` is infallible // but reads like a panicking unwrap at the call site. - let entries = match self.bounds { - AxisRangeBounds::Count { lo, hi } => { - let CostContext { value, cost: _ } = drive.grove.indexed_count_range_keys( - path_refs.as_slice(), - lo, - hi, - self.descending, - self.limit, - transaction, - grove_version, - ); - value - .map_err(|e| Error::GroveDB(Box::new(e)))? - .into_iter() - .map(|(count, key)| RankedEntry { - key, - value: RankedEntryValue::Count(count), - }) - .collect::>() - } - AxisRangeBounds::Sum { lo, hi } => { - let CostContext { value, cost: _ } = drive.grove.indexed_sum_range_keys( - path_refs.as_slice(), - lo, - hi, - self.descending, - self.limit, - transaction, - grove_version, - ); - value - .map_err(|e| Error::GroveDB(Box::new(e)))? - .into_iter() - .map(|(sum, key)| RankedEntry { - key, - value: RankedEntryValue::Sum(sum), - }) - .collect::>() - } - AxisRangeBounds::Avg { lo, hi } => { - let CostContext { value, cost: _ } = drive.grove.indexed_avg_range_keys( - path_refs.as_slice(), - lo, - hi, - self.descending, - self.limit, - transaction, - grove_version, - ); - value - .map_err(|e| Error::GroveDB(Box::new(e)))? - .into_iter() - .map(|(avg, key)| RankedEntry { - key, - value: RankedEntryValue::AvgFixedPoint(avg), - }) - .collect::>() + let CostContext { value, cost: _ } = drive.grove.run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryPathKeyElementTrioResultType, + transaction, + grove_version, + ); + // `skipped` is the paginated traversal's field and is `None` + // for bounded ones — nothing to check here. + let PathQueryRun::AxisKeys { keys, skipped: _ } = + value.map_err(|e| Error::GroveDB(Box::new(e)))? + else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "having {:?} range read ran to a non-axis-keys result shape", + self.bounds.axis() + )))); + }; + + let entries = match (self.bounds.axis(), keys) { + (RankedAxis::Count, AxisKeys::Count(pairs)) => pairs + .into_iter() + .map(|(count, key)| RankedEntry { + key, + value: RankedEntryValue::Count(count), + }) + .collect::>(), + (RankedAxis::Sum, AxisKeys::Sum(pairs)) => pairs + .into_iter() + .map(|(sum, key)| RankedEntry { + key, + value: RankedEntryValue::Sum(sum), + }) + .collect::>(), + (RankedAxis::Avg, AxisKeys::Avg(pairs)) => pairs + .into_iter() + .map(|(avg, key)| RankedEntry { + key, + value: RankedEntryValue::AvgFixedPoint(avg), + }) + .collect::>(), + (axis, other) => { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "having {axis:?} range read returned {} pairs of a different axis shape", + other.len() + )))); } }; @@ -120,57 +127,52 @@ impl DriveDocumentHavingQuery<'_> { Ok(entries) } - /// Generate the grovedb indexed-axis range proof for this query. + /// Generate the bounded-axis proof for this query, through the + /// unified `PathQuery` surface (grovedb's only public proof surface + /// for indexed-axis reads): the query is + /// [`PathQuery::new_axis_bounded`] and the envelope is a GroveDBProof + /// V1 carrying an axis descent into the queried secondary. /// /// The envelope commits the in-range secondary entries, the - /// primary's root hash, the sibling axes' root hashes, and a - /// per-ancestor attestation chain up to the grovedb root — so the - /// client reconstructs the platform root hash from it. The Merk - /// query (the encoded bounds and walk direction) and the limit are - /// echoed and re-checked by grovedb's verifier against the client's - /// own reconstruction via [`AxisRangeBounds::merk_query`] — which is - /// why the bounds are validated rather than clamped upstream, and - /// why completeness needs no extra machinery: a Merk range proof - /// over a sorted keyspace commits its boundaries, so an in-range - /// group the server omitted fails reconstruction. + /// primary's root hash, the sibling axes' root hashes, and the + /// ordinary layer chain up to the grovedb root — so the client + /// reconstructs the platform root hash from it. Nothing is echoed: + /// the verifier takes the client's own reconstruction of the same + /// `PathQuery` as input, and grovedb lowers its bounds into the + /// secondary's keyspace through one function shared by both proof + /// sides — which is why the bounds are validated rather than clamped + /// upstream, and why completeness needs no extra machinery: a Merk + /// range proof over a sorted keyspace commits its boundaries, so an + /// in-range group the server omitted fails reconstruction. /// /// Verified by /// [`DriveDocumentHavingQuery::verify_having_range_proof`](crate::query::DriveDocumentHavingQuery::verify_having_range_proof). pub fn execute_range_with_proof( &self, drive: &Drive, - transaction: TransactionArg, + _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { let grove_version = &platform_version.drive.grove_version; let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); - let secondary_query = self.bounds.merk_query(self.descending); + let (lo, hi) = self.bounds.i128_bounds(); + let path_query = PathQuery::new_axis_bounded( + path, + self.bounds.axis().into(), + lo, + hi, + self.limit, + self.descending, + ); + // The unified prover proves committed state — it takes no + // transaction. The parameter is kept for signature stability with + // the no-proof executor; the query dispatch passes `None` on this + // surface anyway (queries answer from committed state). + // // Same destructure-don't-unwrap rationale as the no-proof arm. - let CostContext { value, cost: _ } = match self.bounds.axis() { - RankedAxis::Count => drive.grove.prove_indexed_count_query( - path_refs.as_slice(), - secondary_query, - Some(self.limit), - transaction, - grove_version, - ), - RankedAxis::Sum => drive.grove.prove_indexed_sum_query( - path_refs.as_slice(), - secondary_query, - Some(self.limit), - transaction, - grove_version, - ), - RankedAxis::Avg => drive.grove.prove_indexed_avg_query( - path_refs.as_slice(), - secondary_query, - Some(self.limit), - transaction, - grove_version, - ), - }; + let CostContext { value, cost: _ } = + drive.grove.prove_query(&path_query, None, grove_version); value.map_err(|e| Error::GroveDB(Box::new(e))) } } diff --git a/packages/rs-drive/src/query/drive_document_having_query/mod.rs b/packages/rs-drive/src/query/drive_document_having_query/mod.rs index f0418fc798a..dcf17d0b883 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/mod.rs @@ -37,13 +37,15 @@ //! rank `offset`); having-range addresses them by **value bound** //! (`aggregate ∈ [lo, hi]`). Three consequences: //! -//! 1. **The bound is part of the proof contract.** The grovedb envelope -//! for a range read echoes the Merk query itself, and the verifier -//! re-builds that query from the request's bounds -//! ([`AxisRangeBounds::merk_query`]) — so prover and verifier must -//! share one bounds-to-query translation, exactly as they share the -//! grove path. Completeness comes from the Merk range proof: the -//! boundary commitments show no in-range group was omitted. +//! 1. **The bound is part of the proof contract.** Both proof sides +//! build the same [`grovedb::PathQuery::new_axis_bounded`] from the +//! request's bounds ([`AxisRangeBounds::i128_bounds`]), and grovedb +//! lowers those bounds into the secondary's keyspace through one +//! shared function on both the prover and the verifier — so the two +//! sides cannot drift on which range a proof is about, exactly as +//! they share the grove path. Completeness comes from the Merk range +//! proof: the boundary commitments show no in-range group was +//! omitted. //! 2. **No `OFFSET`, no `start_at` — and no full pagination.** The //! range primitives take a limit but no skip, and a request carrying //! either knob is rejected loudly. A page cut at `limit` can only be @@ -85,8 +87,6 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; #[cfg(any(feature = "server", feature = "verify"))] use grovedb::element::indexed::{encode_avg_sort_key, encode_count_sort_key, encode_sum_sort_key}; -#[cfg(any(feature = "server", feature = "verify"))] -use grovedb::Query; #[cfg(any(feature = "server", feature = "verify"))] pub mod mode_detection; @@ -196,23 +196,26 @@ impl AxisRangeBounds { } } - /// The Merk query over the axis secondary that reads exactly these - /// bounds, walking in the requested direction. + /// The inclusive bounds widened to `i128` — the domain + /// [`grovedb::PathQuery::new_axis_bounded`] takes for every axis. + /// + /// The prover/verifier-agreement artifact of the having surface is + /// grovedb's own bounded-axis lowering: both proof sides lower the + /// same `PathQuery` bounds into the secondary's keyspace through one + /// shared function inside grovedb, producing exactly the byte range + /// [`Self::secondary_key_bounds`] documents — inclusive at + /// `encode(lo)`, exclusive at `encode(hi + 1)`, open-ended at the + /// axis maximum. Platform no longer builds the Merk query itself, so + /// the two sides cannot drift on which range a proof is about. /// - /// This is the **prover/verifier-agreement artifact** of the having - /// surface: grovedb's range-proof envelope is generated against this - /// query and verified against the verifier's own reconstruction of - /// it, so both sides must build it from the same bounds through this - /// one function — a divergence surfaces as a failed verification, - /// not a wrong answer. - pub fn merk_query(&self, descending: bool) -> Query { - let (lower, upper) = self.secondary_key_bounds(); - let mut query = Query::new_with_direction(!descending); - match upper { - Some(upper) => query.insert_range(lower..upper), - None => query.insert_range_from(lower..), + /// Widening is lossless: `u64` and `i64` both embed in `i128`, and + /// grovedb clamps back to each axis's own domain when lowering. + pub fn i128_bounds(&self) -> (i128, i128) { + match *self { + AxisRangeBounds::Count { lo, hi } => (lo as i128, hi as i128), + AxisRangeBounds::Sum { lo, hi } => (lo as i128, hi as i128), + AxisRangeBounds::Avg { lo, hi } => (lo, hi), } - query } } @@ -253,8 +256,9 @@ pub struct DocumentHavingMode { /// A resolved having-range query. Shared by the prover and the verifier — /// both build the grove path through /// [`DriveDocumentHavingQuery::indexed_property_name_tree_path`] and the -/// secondary query through [`AxisRangeBounds::merk_query`], so the two -/// cannot drift on which subtree or which range the proof is about. +/// same bounded axis `PathQuery` through +/// [`AxisRangeBounds::i128_bounds`], so the two cannot drift on which +/// subtree or which range the proof is about. #[derive(Debug, Clone)] #[cfg(any(feature = "server", feature = "verify"))] pub struct DriveDocumentHavingQuery<'a> { diff --git a/packages/rs-drive/src/query/drive_document_having_query/tests.rs b/packages/rs-drive/src/query/drive_document_having_query/tests.rs index 496a199d79a..702fb5d7277 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/tests.rs @@ -557,10 +557,27 @@ mod bounds { } #[test] - fn merk_query_direction_follows_descending() { - let bounds = AxisRangeBounds::Count { lo: 101, hi: 200 }; - assert!(bounds.merk_query(false).left_to_right); - assert!(!bounds.merk_query(true).left_to_right); + fn i128_bounds_widen_every_axis_losslessly() { + assert_eq!( + AxisRangeBounds::Count { + lo: 101, + hi: u64::MAX + } + .i128_bounds(), + (101, u64::MAX as i128) + ); + assert_eq!( + AxisRangeBounds::Sum { + lo: i64::MIN, + hi: -5 + } + .i128_bounds(), + (i64::MIN as i128, -5) + ); + assert_eq!( + AxisRangeBounds::Avg { lo: -5, hi: 5 }.i128_bounds(), + (-5, 5) + ); } } @@ -1049,24 +1066,19 @@ mod execution { let (drive, contract) = setup_restaurants(); // Empty secondary (no documents at all): the unproven read - // returns the empty list, but grovedb's range prover — unlike - // the ranked surface's paginated prover — has no absence-proof - // shape for a completely empty tree and refuses. drive-abci - // maps this exact failure class onto an `InvalidArgument` - // telling the caller to retry unproved - // (`empty_ranking_proof_rejection`); at the drive level it - // surfaces as the grovedb error asserted here. If a future - // grovedb pin makes empty range proofs work, this arm should - // flip to a round-trip assertion. + // returns the empty list, and the unified bounded-axis prover + // proves it — an empty secondary is carried as empty proof + // bytes that the verifier resolves to a NULL_HASH secondary + // root, so the parent binding only passes when the element + // genuinely commits an empty secondary. (The old standalone + // range prover refused this state outright with "Cannot create + // proof for empty tree", which is why drive-abci still keeps + // `empty_ranking_proof_rejection` as a backstop for that error + // class.) let case = HavingCase::count(HavingOperator::GreaterThan, Value::U64(100), 10); let entries = entries_of(run(&drive, &contract, &case, false).expect("read succeeds")); assert!(entries.is_empty()); - let error = run(&drive, &contract, &case, true) - .expect_err("proving against an empty secondary is refused by grovedb"); - assert!( - format!("{error}").contains("Cannot create proof for empty tree"), - "the failure must be the recognized empty-tree class, got: {error}" - ); + assert_proof_round_trips(&drive, &contract, &case, &entries); // Populated secondary, bound above every count: a genuine // absence proof, which works — the tree has content to anchor @@ -1095,6 +1107,14 @@ mod execution { /// verify under both — correctly, because the range boundaries /// prove both claims — so the distinguishing group is the point of /// the fixture. + /// + /// The unified verifier is query-as-input rather than echo-checked: + /// the invariant it holds is "a proof can never make the client + /// believe a wrong answer to the client's own query", not "a proof + /// only verifies under the exact request it was built for". A + /// tampered query whose answer the proof also correctly attests + /// (the limit case at the end) therefore verifies — to that + /// query's own correct answer. #[test] fn a_proof_does_not_verify_under_different_bounds() { let (drive, contract) = setup_restaurants(); @@ -1139,18 +1159,27 @@ mod execution { "a proof of `> 2` must not verify as `> 1`" ); - // Nor under a different direction or limit. + // Nor under a different direction. tampered_query = client_side_query(&contract, &over_two); tampered_query.descending = true; assert!(tampered_query .verify_having_range_proof(&proof, platform_version()) .is_err()); + // A different limit is NOT an echo check under the unified + // query-as-input verifier: the same bytes verify under any limit + // they can correctly answer. Here exactly one group matches + // `> 2`, so limit 5 and limit 10 have the same answer and the + // proof verifies to it — the sound outcome, since the entries + // ARE the right answer to the limit-5 question. What can never + // happen is an over-long answer: the verifier's own shape check + // caps entries at the query's limit. tampered_query = client_side_query(&contract, &over_two); tampered_query.limit = 5; - assert!(tampered_query + let (_, entries) = tampered_query .verify_having_range_proof(&proof, platform_version()) - .is_err()); + .expect("a proof whose answer also answers the smaller limit verifies under it"); + assert_eq!(keys_of(&entries), vec!["beta"]); } /// A `having` on an axis no index declares is refused with the diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/execute_top_k.rs b/packages/rs-drive/src/query/drive_document_ranked_query/execute_top_k.rs index 9cda62c9234..e24b07b3aa8 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/execute_top_k.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/execute_top_k.rs @@ -15,8 +15,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use dpp::version::PlatformVersion; -use grovedb::{IndexedTopKKeysPage, TransactionArg}; +use grovedb::query_result_type::QueryResultType; +use grovedb::{AxisKeys, PathQuery, PathQueryRun, TransactionArg}; use grovedb_costs::CostContext; +use grovedb_query::AxisQuery; impl DriveDocumentRankedQuery<'_> { /// Read one page of the ranking directly from the axis secondary: @@ -73,8 +75,20 @@ impl DriveDocumentRankedQuery<'_> { ) -> Result { let grove_version = &platform_version.drive.grove_version; let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); - let offset = self.offset as u64; + + // The same axis PathQuery the prove path uses, with the + // keys-only projection: the ranking pairs are read straight off + // the pinned secondary view, no primary values resolved. + let path_query = PathQuery::new_axis( + path, + AxisQuery::top_k( + self.axis.into(), + self.k, + self.offset as u64, + self.descending, + ) + .keys_only(), + ); // The cost is dropped rather than `.unwrap()`-ed: // `CostContext::unwrap` is infallible (it drops the cost field) @@ -84,73 +98,57 @@ impl DriveDocumentRankedQuery<'_> { // above it accumulates or charges the cost, and no credit is // debited for a read. grovedb computes the `OperationCost` // because its API always does, and it ends here. - let (entries, skipped) = match self.axis { - RankedAxis::Count => { - let CostContext { value, cost: _ } = - drive.grove.indexed_count_top_k_paginated_keys( - path_refs.as_slice(), - self.k, - offset, - self.descending, - transaction, - grove_version, - ); - let IndexedTopKKeysPage { entries, skipped } = - value.map_err(|e| Error::GroveDB(Box::new(e)))?; - ( - entries - .into_iter() - .map(|(count, key)| RankedEntry { - key, - value: RankedEntryValue::Count(count), - }) - .collect::>(), - skipped, - ) - } - RankedAxis::Sum => { - let CostContext { value, cost: _ } = drive.grove.indexed_sum_top_k_paginated_keys( - path_refs.as_slice(), - self.k, - offset, - self.descending, - transaction, - grove_version, - ); - let IndexedTopKKeysPage { entries, skipped } = - value.map_err(|e| Error::GroveDB(Box::new(e)))?; - ( - entries - .into_iter() - .map(|(sum, key)| RankedEntry { - key, - value: RankedEntryValue::Sum(sum), - }) - .collect::>(), - skipped, - ) - } - RankedAxis::Avg => { - let CostContext { value, cost: _ } = drive.grove.indexed_avg_top_k_paginated_keys( - path_refs.as_slice(), - self.k, - offset, - self.descending, - transaction, - grove_version, - ); - let IndexedTopKKeysPage { entries, skipped } = - value.map_err(|e| Error::GroveDB(Box::new(e)))?; - ( - entries - .into_iter() - .map(|(avg, key)| RankedEntry { - key, - value: RankedEntryValue::AvgFixedPoint(avg), - }) - .collect::>(), - skipped, - ) + let CostContext { value, cost: _ } = drive.grove.run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryPathKeyElementTrioResultType, + transaction, + grove_version, + ); + let PathQueryRun::AxisKeys { keys, skipped } = + value.map_err(|e| Error::GroveDB(Box::new(e)))? + else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "ranked {:?} read ran to a non-axis-keys result shape", + self.axis + )))); + }; + let skipped = skipped.ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "ranked {:?} read carried no skip count for a paginated walk", + self.axis + ))) + })?; + + let entries = match (self.axis, keys) { + (RankedAxis::Count, AxisKeys::Count(pairs)) => pairs + .into_iter() + .map(|(count, key)| RankedEntry { + key, + value: RankedEntryValue::Count(count), + }) + .collect::>(), + (RankedAxis::Sum, AxisKeys::Sum(pairs)) => pairs + .into_iter() + .map(|(sum, key)| RankedEntry { + key, + value: RankedEntryValue::Sum(sum), + }) + .collect::>(), + (RankedAxis::Avg, AxisKeys::Avg(pairs)) => pairs + .into_iter() + .map(|(avg, key)| RankedEntry { + key, + value: RankedEntryValue::AvgFixedPoint(avg), + }) + .collect::>(), + (axis, other) => { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "ranked {axis:?} read returned {} pairs of a different axis shape", + other.len() + )))); } }; @@ -170,21 +168,25 @@ impl DriveDocumentRankedQuery<'_> { Ok(RankedPage { skipped, entries }) } - /// Generate the grovedb indexed-axis paginated top-k proof for this - /// query. + /// Generate the axis-ordered top-k proof for this query, through the + /// unified `PathQuery` surface (grovedb's only public proof surface + /// for indexed-axis reads): the query is + /// [`PathQuery::new_axis_top_k`] and the envelope is a GroveDBProof + /// V1 carrying an axis descent into the queried secondary. /// /// The envelope commits the walked secondary entries, the number of /// entries skipped to reach them, the primary's root hash, the - /// sibling axes' root hashes, and a per-ancestor attestation chain - /// up to the grovedb root — so the client reconstructs the platform - /// root hash from it. It also echoes `(axis, k, offset, - /// descending)`, which - /// [`grovedb::GroveDb::verify_indexed_axis_top_k_paginated`] - /// re-checks against what the client asked for; that is why `k` is + /// sibling axes' root hashes, and the ordinary layer chain up to the + /// grovedb root — so the client reconstructs the platform root hash + /// from it. `(axis, k, offset, descending)` are **not echoed** in the + /// envelope: the verifier takes the client's own reconstruction of + /// the same `PathQuery` as input, so a proof generated for a + /// different ranking — or a different page — fails verification + /// rather than being silently reinterpreted. That is why `k` is /// validated rather than clamped upstream (a clamped `k` would - /// produce a proof the client's own reconstruction rejects). + /// produce a proof the client's own query rejects). /// - /// The paginated primitive is used unconditionally, with + /// The paginated traversal is used unconditionally, with /// `offset = 0` for offset-free requests, so there is exactly one /// proof shape on this surface: a client never has to guess which of /// two envelope formats a server produced. @@ -204,23 +206,27 @@ impl DriveDocumentRankedQuery<'_> { pub fn execute_top_k_with_proof( &self, drive: &Drive, - transaction: TransactionArg, + _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { let grove_version = &platform_version.drive.grove_version; let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); - - // Same destructure-don't-unwrap rationale as the no-proof arm. - let CostContext { value, cost: _ } = drive.grove.prove_indexed_axis_top_k_paginated( - path_refs.as_slice(), + let path_query = PathQuery::new_axis_top_k( + path, self.axis.into(), self.k, self.offset as u64, self.descending, - transaction, - grove_version, ); + + // The unified prover proves committed state — it takes no + // transaction. The parameter is kept for signature stability with + // the no-proof executor; the query dispatch passes `None` on this + // surface anyway (queries answer from committed state). + // + // Same destructure-don't-unwrap rationale as the no-proof arm. + let CostContext { value, cost: _ } = + drive.grove.prove_query(&path_query, None, grove_version); value.map_err(|e| Error::GroveDB(Box::new(e))) } } diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs b/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs index a72ccd7b7f4..68a52700540 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs @@ -117,10 +117,10 @@ mod tests; /// is rejected with /// [`crate::error::query::QuerySyntaxError::InvalidLimit`] rather than /// silently truncated. Truncation would be especially treacherous here -/// because `k` is echoed inside the proof envelope and re-checked by -/// [`grovedb::GroveDb::verify_indexed_axis_top_k_paginated`] — a -/// server-side clamp would produce a proof the client's own -/// reconstruction rejects. +/// because the proof is verified against the client's own +/// reconstruction of the same axis `PathQuery` (with the client's `k`) +/// by [`grovedb::GroveDb::verify_path_query`] — a server-side clamp +/// would produce a proof the client's own query rejects. /// /// There is deliberately **no companion ceiling on `OFFSET`**; see the /// module docs and [`DriveDocumentRankedQuery::offset`]. diff --git a/packages/rs-drive/src/verify/document_having/mod.rs b/packages/rs-drive/src/verify/document_having/mod.rs index a3ddcec97e1..06d35824f66 100644 --- a/packages/rs-drive/src/verify/document_having/mod.rs +++ b/packages/rs-drive/src/verify/document_having/mod.rs @@ -10,9 +10,9 @@ //! //! Only one verifier exists here, for the same reason as on the ranked //! surface: every having-range request — any of the three axes, either -//! direction, any contiguous bound — resolves to one -//! `prove_indexed_axis_query` envelope that differs only in the Merk -//! query (the encoded bounds + direction) and limit it echoes. +//! direction, any contiguous bound — resolves to one bounded-axis +//! `PathQuery` proved through grovedb's unified `prove_query`, differing +//! only in the axis, bounds, direction and limit the query carries. /// Indexed-axis range proof verification — returns the groups the proof /// commits to as falling inside the bound, in axis order. diff --git a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs index 893a2766bbb..9f202485af0 100644 --- a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs +++ b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs @@ -14,8 +14,8 @@ impl DriveDocumentHavingQuery<'_> { /// [`execute_range_with_proof`](Self::execute_range_with_proof). /// Both sides derive the proved subtree from /// [`indexed_property_name_tree_path`](Self::indexed_property_name_tree_path) - /// and the secondary query from - /// [`AxisRangeBounds::merk_query`](crate::query::drive_document_having_query::AxisRangeBounds::merk_query), + /// and the same bounded axis `PathQuery` from + /// [`AxisRangeBounds::i128_bounds`](crate::query::drive_document_having_query::AxisRangeBounds::i128_bounds), /// so the verifier cannot drift from the prover on *which* bound over /// *which* tree it is checking. /// diff --git a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/v0/mod.rs b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/v0/mod.rs index 9caf4f9df85..2cf0a0ebdf2 100644 --- a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/v0/mod.rs @@ -3,31 +3,32 @@ use crate::error::Error; use crate::query::{DriveDocumentHavingQuery, RankedAxis, RankedEntry, RankedEntryValue}; use crate::verify::RootHash; use dpp::version::PlatformVersion; -use grovedb::operations::proof::indexed_axis::AxisEntries; -use grovedb::GroveDb; +use grovedb::operations::proof::{indexed_axis::AxisEntries, VerifiedPathQuery}; +use grovedb::{GroveDb, PathQuery}; impl DriveDocumentHavingQuery<'_> { /// v0 of [`Self::verify_having_range_proof`]. /// - /// Rebuilds the proved subtree path with - /// [`Self::indexed_property_name_tree_path`] and the secondary query - /// with - /// [`AxisRangeBounds::merk_query`](crate::query::drive_document_having_query::AxisRangeBounds::merk_query), - /// then hands the proof to the matching - /// `GroveDb::verify_indexed_*_query` — an associated function, no - /// database handle, so this compiles and runs in a verifier-only - /// build. + /// Rebuilds the same [`PathQuery::new_axis_bounded`] the prover used + /// — the path via [`Self::indexed_property_name_tree_path`], the + /// bounds via + /// [`AxisRangeBounds::i128_bounds`](crate::query::drive_document_having_query::AxisRangeBounds::i128_bounds) + /// — then hands the proof to [`GroveDb::verify_path_query`] — an + /// associated function, no database handle, so this compiles and + /// runs in a verifier-only build. /// /// Three things are checked before the entries are returned: /// - /// 1. **The envelope matches this query.** grovedb re-checks the - /// proof against the reconstructed Merk query (the encoded bounds - /// and walk direction) and the expected limit, so a proof - /// generated for a different bound — or a different direction, or - /// a different limit — is rejected rather than silently - /// reinterpreted. Completeness rides on the same check: a Merk - /// range proof commits its boundaries, so an in-range group the - /// prover omitted fails reconstruction. + /// 1. **The proof answers this query.** The unified verifier is + /// query-as-input: nothing is echoed in the envelope; grovedb + /// lowers the query's bounds into the secondary's keyspace + /// through the same function the prover used and verifies the + /// proof against that reconstruction, so a proof generated for a + /// different bound — or a different direction, or a different + /// limit — is rejected rather than silently reinterpreted. + /// Completeness rides on the same check: a Merk range proof + /// commits its boundaries, so an in-range group the prover + /// omitted fails reconstruction. /// 2. **The result's axis shape matches the requested axis** — the /// same belt-and-braces check the ranked verifier does. /// 3. **At most `limit` entries.** Fewer is normal — fewer groups @@ -44,35 +45,35 @@ impl DriveDocumentHavingQuery<'_> { platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec), Error> { let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); - let secondary_query = self.bounds.merk_query(self.descending); + let (lo, hi) = self.bounds.i128_bounds(); + let path_query = PathQuery::new_axis_bounded( + path, + self.bounds.axis().into(), + lo, + hi, + self.limit, + self.descending, + ); - let result = match self.bounds.axis() { - RankedAxis::Count => GroveDb::verify_indexed_count_query( - proof, - path_refs.as_slice(), - secondary_query, - Some(self.limit), - &platform_version.drive.grove_version, - ), - RankedAxis::Sum => GroveDb::verify_indexed_sum_query( - proof, - path_refs.as_slice(), - secondary_query, - Some(self.limit), - &platform_version.drive.grove_version, - ), - RankedAxis::Avg => GroveDb::verify_indexed_avg_query( - proof, - path_refs.as_slice(), - secondary_query, - Some(self.limit), - &platform_version.drive.grove_version, - ), - } - .map_err(|e| Error::GroveDB(Box::new(e)))?; + let verified = + GroveDb::verify_path_query(proof, &path_query, &platform_version.drive.grove_version) + .map_err(|e| Error::GroveDB(Box::new(e)))?; + + // `skipped` is the paginated traversal's field and is `None` for + // bounded ones — nothing to check here. + let VerifiedPathQuery::AxisEntries { + root_hash, + entries, + skipped: _, + } = verified + else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "having range proof for the {:?} axis verified to a non-axis result shape", + self.bounds.axis() + )))); + }; - let entries = match (self.bounds.axis(), result.entries) { + let entries = match (self.bounds.axis(), entries) { (RankedAxis::Count, AxisEntries::Count(entries)) => entries .into_iter() .map(|entry| entry.key_pair()) @@ -115,6 +116,6 @@ impl DriveDocumentHavingQuery<'_> { )))); } - Ok((result.root_hash, entries)) + Ok((root_hash, entries)) } } diff --git a/packages/rs-drive/src/verify/document_ranked/mod.rs b/packages/rs-drive/src/verify/document_ranked/mod.rs index 0a5a0e0b8a7..d99a6b887f1 100644 --- a/packages/rs-drive/src/verify/document_ranked/mod.rs +++ b/packages/rs-drive/src/verify/document_ranked/mod.rs @@ -12,8 +12,9 @@ //! single proof shape. Where the count surface has five verifiers because //! five different grovedb primitives can answer a count, every ranked //! request — either direction, at any offset, on any of the three axes — -//! resolves to one `prove_indexed_axis_top_k_paginated` envelope that -//! differs only in the `(axis, k, descending, offset)` tuple it echoes. +//! resolves to one top-k axis `PathQuery` proved through grovedb's +//! unified `prove_query`, differing only in the `(axis, k, descending, +//! offset)` tuple the query carries. /// Indexed-axis top-k proof verification — returns the ranked groups the /// proof commits to, in ranking order. diff --git a/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/v0/mod.rs b/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/v0/mod.rs index 82f5b0a2320..6e540052520 100644 --- a/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/v0/mod.rs @@ -5,31 +5,33 @@ use crate::query::{ }; use crate::verify::RootHash; use dpp::version::PlatformVersion; -use grovedb::operations::proof::indexed_axis::AxisEntries; -use grovedb::GroveDb; +use grovedb::operations::proof::{indexed_axis::AxisEntries, VerifiedPathQuery}; +use grovedb::{GroveDb, PathQuery}; impl DriveDocumentRankedQuery<'_> { /// v0 of [`Self::verify_ranked_top_k_proof`]. /// - /// Rebuilds the proved subtree path with - /// [`Self::indexed_property_name_tree_path`] and hands the proof to - /// [`GroveDb::verify_indexed_axis_top_k_paginated`], which is an - /// associated function — no database handle is involved, so this - /// compiles and runs in a verifier-only build. + /// Rebuilds the same [`PathQuery::new_axis_top_k`] the prover used — + /// path via [`Self::indexed_property_name_tree_path`], the axis / + /// `k` / offset / direction from this query — and hands the proof to + /// [`GroveDb::verify_path_query`], which is an associated function — + /// no database handle is involved, so this compiles and runs in a + /// verifier-only build. /// /// Three things are checked before the page is returned: /// - /// 1. **The envelope's `(axis, k, offset, descending)` match this - /// query.** grovedb does this itself: the values are echoed in - /// the proof and compared against the arguments, so a proof - /// generated for a different ranking — or a different page of the - /// same ranking — is rejected rather than silently reinterpreted. + /// 1. **The proof answers this query.** The unified verifier is + /// query-as-input: nothing is echoed in the envelope; the proof is + /// verified against the verifier's own reconstruction of the + /// `PathQuery`, so a proof generated for a different ranking — or + /// a different page of the same ranking — fails verification + /// rather than being silently reinterpreted. /// 2. **The result's axis shape matches the requested axis** — a /// `Count` request must not come back holding `Sum` entries. This - /// is belt-and-braces on top of (1) (the tag check already rules - /// it out) and exists so a future decoder change that decouples - /// the tag from the decoded variant surfaces as an error here - /// instead of a mis-typed number reaching the caller. + /// is belt-and-braces on top of (1) (the query's axis already + /// rules it out) and exists so a future decoder change that + /// decouples the query from the decoded variant surfaces as an + /// error here instead of a mis-typed number reaching the caller. /// 3. **At most `k` entries.** Fewer is normal — the index may hold /// fewer groups than were asked for — but more would mean the /// proof committed a longer walk than the request authorized. @@ -52,20 +54,41 @@ impl DriveDocumentRankedQuery<'_> { platform_version: &PlatformVersion, ) -> Result<(RootHash, RankedPage), Error> { let path = self.indexed_property_name_tree_path()?; - let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); - - let result = GroveDb::verify_indexed_axis_top_k_paginated( - proof, - path_refs.as_slice(), + let path_query = PathQuery::new_axis_top_k( + path, self.axis.into(), self.k, self.offset as u64, self.descending, - &platform_version.drive.grove_version, - ) - .map_err(|e| Error::GroveDB(Box::new(e)))?; + ); + + let verified = + GroveDb::verify_path_query(proof, &path_query, &platform_version.drive.grove_version) + .map_err(|e| Error::GroveDB(Box::new(e)))?; + + let VerifiedPathQuery::AxisEntries { + root_hash, + entries, + skipped, + } = verified + else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "ranked top-k proof for the {:?} axis verified to a non-axis result shape", + self.axis + )))); + }; + + // A paginated (top-k) traversal always attests its skip count; + // `None` is the bounded traversal's shape and cannot answer this + // query. + let skipped = skipped.ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "ranked top-k proof for the {:?} axis carried no attested skip count", + self.axis + ))) + })?; - let entries = match (self.axis, result.entries) { + let entries = match (self.axis, entries) { (RankedAxis::Count, AxisEntries::Count(entries)) => entries .into_iter() .map(|entry| entry.key_pair()) @@ -108,12 +131,6 @@ impl DriveDocumentRankedQuery<'_> { )))); } - Ok(( - result.root_hash, - RankedPage { - skipped: result.skipped, - entries, - }, - )) + Ok((root_hash, RankedPage { skipped, entries })) } } diff --git a/packages/rs-platform-version/Cargo.toml b/packages/rs-platform-version/Cargo.toml index 4ee353c872e..74faf5deaf6 100644 --- a/packages/rs-platform-version/Cargo.toml +++ b/packages/rs-platform-version/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" thiserror = { version = "2.0.12" } bincode = { version = "=2.0.1" } versioned-feature-core = { git = "https://github.com/dashpay/versioned-feature-core", version = "1.0.0" } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e" } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" } [features] mock-versions = [] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index 84af8082e48..58280be26b8 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -76,8 +76,8 @@ pub struct DriveVerifyDocumentSumMethodVersions { /// Versions for the indexed-axis prove-path verifiers: the ranked /// (top-k) verifier and the boolean-`HAVING` range verifier. Both are /// implemented on the respective drive query types and delegate to -/// grovedb's indexed-axis proof verification -/// (`verify_indexed_axis_top_k_paginated` / `verify_indexed_axis_query`). +/// grovedb's unified `verify_path_query` over the query's axis +/// `PathQuery` (`new_axis_top_k` / `new_axis_bounded`). #[derive(Clone, Debug, Default)] pub struct DriveVerifyDocumentRankedMethodVersions { pub verify_ranked_top_k_proof: FeatureVersion, diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 755f7bb110b..a46939a40ca 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -69,7 +69,7 @@ zeroize = "1" log = "0.4" # Shielded pool (optional, behind `shielded` feature) -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", optional = true } # Direct `rusqlite` access so `FileBackedShieldedStore::open_path` can set # WAL + synchronous=NORMAL pragmas before handing the connection to # `ClientPersistentCommitmentTree`. Version locked to match the rev grovedb diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index ee1bfca14ae..95a53f942cf 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -18,7 +18,7 @@ drive = { path = "../rs-drive", default-features = false, features = [ ] } drive-proof-verifier = { path = "../rs-drive-proof-verifier", default-features = false } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "753a11f14c9a4bc72bf2d5302751dd43d174621e", features = [ +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "6c882c3ee7d2c331f1feda2eb4223add9a6f0e45", features = [ "client", "sqlite", ], optional = true } From cf759a34b27012e4254981e3d88ac3d30cc9664d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 19:25:43 +0200 Subject: [PATCH 16/20] fix(drive-abci): eta-reduce the savepoint-rollback error mapping The unified-PathQuery grovedb pin (#4488) changed rollback_to_savepoint()'s error type so the RocksDBError wrap went away, leaving |e| StorageError(e) closures that clippy 1.92 rejects as redundant_closure under CI's -D warnings. Co-Authored-By: Claude Fable 5 --- packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs | 3 ++- packages/rs-drive-abci/src/abci/handler/process_proposal.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs index e7191125946..3a77fabd397 100644 --- a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs @@ -1,6 +1,7 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; use crate::error::Error; +use drive::grovedb::error::Error as GroveDBError; use crate::execution::engine::consensus_params_update::consensus_params_update; use crate::execution::types::block_execution_context::v0::{ BlockExecutionContextV0Getters, BlockExecutionContextV0Setters, @@ -141,7 +142,7 @@ where ); if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() - .map_err(|e| drive::grovedb::error::Error::StorageError(e))?; + .map_err(GroveDBError::StorageError)?; tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index ce898a7dc77..0292f602d06 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -1,6 +1,7 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; use crate::error::Error; +use drive::grovedb::error::Error as GroveDBError; use crate::execution::engine::consensus_params_update::consensus_params_update; use crate::execution::types::block_execution_context::v0::{ BlockExecutionContextV0Getters, BlockExecutionContextV0MutableGetters, @@ -172,7 +173,7 @@ where ); if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() - .map_err(|e| drive::grovedb::error::Error::StorageError(e))?; + .map_err(GroveDBError::StorageError)?; tx.set_savepoint(); } transaction_guard From 60b786b823a4f0a2f7e1e70daee7d8943363457f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 19:39:16 +0200 Subject: [PATCH 17/20] style(drive-abci): sort the GroveDBError import where rustfmt puts it Co-Authored-By: Claude Fable 5 --- packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs | 2 +- packages/rs-drive-abci/src/abci/handler/process_proposal.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs index 3a77fabd397..5a0361b66ac 100644 --- a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs @@ -1,7 +1,6 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; use crate::error::Error; -use drive::grovedb::error::Error as GroveDBError; use crate::execution::engine::consensus_params_update::consensus_params_update; use crate::execution::types::block_execution_context::v0::{ BlockExecutionContextV0Getters, BlockExecutionContextV0Setters, @@ -14,6 +13,7 @@ use crate::rpc::core::CoreRPCLike; use dpp::dashcore::hashes::Hash; use dpp::dashcore::Network; use dpp::version::TryIntoPlatformVersioned; +use drive::grovedb::error::Error as GroveDBError; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::tx_record::TxAction; use tenderdash_abci::proto::abci::{ExecTxResult, TxRecord}; diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index 0292f602d06..db05c55fb77 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -1,7 +1,6 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; use crate::error::Error; -use drive::grovedb::error::Error as GroveDBError; use crate::execution::engine::consensus_params_update::consensus_params_update; use crate::execution::types::block_execution_context::v0::{ BlockExecutionContextV0Getters, BlockExecutionContextV0MutableGetters, @@ -15,6 +14,7 @@ use crate::platform_types::state_transitions_processing_result::StateTransitionE use crate::rpc::core::CoreRPCLike; use dpp::dashcore::Network; use dpp::version::TryIntoPlatformVersioned; +use drive::grovedb::error::Error as GroveDBError; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::tx_record::TxAction; From aacc446a9ce36239ca409553434420f4e77dfbdf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 21:08:44 +0200 Subject: [PATCH 18/20] test(drive): sibling file for the time-range e2e module; pin cursor ids and the unique layout - Move the 1,550-line time_range_index_e2e_tests module out of add_document_for_contract/mod.rs into a sibling file, leaving the 64-line versioned dispatcher readable. - The in-bucket cursor regression now asserts the exact returned id sequence (deserialized), not just result counts. - New coverage for the unique arm of the in-bucket cursor rule: on a unique single-property grid an included cursor retains the bucket's sole document and an excluded cursor empties the page. Co-Authored-By: Claude Fable 5 --- .../insert/add_document_for_contract/mod.rs | 1552 +--------------- .../time_range_index_e2e_tests.rs | 1632 +++++++++++++++++ 2 files changed, 1633 insertions(+), 1551 deletions(-) create mode 100644 packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index d78617e9fba..28310f15ada 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -64,1554 +64,4 @@ impl Drive { } #[cfg(test)] -mod time_range_index_e2e_tests { - //! End-to-end coverage for time-range index fan-out: a single document is - //! indexed under every overlapping range bucket its `$createdAt` falls - //! into, those buckets are queryable by exact bucket start, and deletion - //! removes every entry. - //! - //! Also covers the other half of that contract: a bucket-start equality - //! only means "bucket" when it came from `IN_TIME_RANGE` resolution, so - //! index selection is pinned by - //! [`DriveDocumentQuery::resolved_time_ranges`] rather than left to - //! whichever index happens to cover the fields. - use crate::config::DriveConfig; - use crate::drive::Drive; - use crate::error::query::QuerySyntaxError; - use crate::error::Error; - use crate::query::{ - resolve_time_range_bucket_clause, DriveDocumentQuery, ResolvedTimeRange, TimeRangeGridSpec, - TimeRangeSelector, - }; - use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; - use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; - use crate::util::storage_flags::StorageFlags; - use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::block::block_info::BlockInfo; - use dpp::data_contract::accessors::v0::DataContractV0Getters; - use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; - use dpp::data_contract::DataContractFactory; - use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; - use dpp::platform_value::{platform_value, Identifier, Value}; - use dpp::prelude::DataContract; - use dpp::version::PlatformVersion; - use std::collections::BTreeMap; - - /// One hour in each of the two units these tests deal in: `*_SECONDS` - /// declares a contract's window, `*_MS` is a document timestamp, a bucket - /// start or an index key. Scaling the wrong one silently shifts the - /// buckets by a factor of a thousand, so they are kept apart by name. - const HOUR_SECONDS: u64 = 3_600; - const HOUR_MS: u64 = 3_600_000; - - /// Deterministic 32-byte fixture identifier derived from the document's - /// own fixture inputs. Identifiers here are plumbing, not test inputs: - /// fixed bytes keep a failing GroveDB fixture reproducible run-to-run and - /// avoid an OS-entropy dependency (and its unwrap) in - /// consensus-sensitive tests. `marker` separates namespaces (document id - /// vs owner) and same-timestamp siblings. - fn fixture_bytes(marker: u8, created_at: u64, tag: &str) -> [u8; 32] { - let mut bytes = [0u8; 32]; - bytes[0] = marker; - bytes[1..9].copy_from_slice(&created_at.to_be_bytes()); - for (i, byte) in tag.bytes().take(23).enumerate() { - bytes[9 + i] = byte; - } - bytes - } - - /// A latest-protocol `post` document type with a `(timeRange($createdAt, range=6h, - /// step=2h), hashtag)` countable index — i.e. trending hashtags over a - /// 6-hour window refreshed every 2 hours (overlap factor 3). - fn build_trending_contract() -> DataContract { - let factory = - DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); - let index_map = vec![ - ( - Value::Text("name".to_string()), - Value::Text("trending".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"$createdAt": "asc"}), - platform_value!({"hashtag": "asc"}), - ]), - ), - ( - Value::Text("timeRange".to_string()), - Value::Map(vec![ - ( - Value::Text("on".to_string()), - Value::Text("$createdAt".to_string()), - ), - ( - Value::Text("range".to_string()), - Value::U64(6 * HOUR_SECONDS), - ), - ( - Value::Text("step".to_string()), - Value::U64(2 * HOUR_SECONDS), - ), - ]), - ), - ( - Value::Text("countable".to_string()), - Value::Text("countable".to_string()), - ), - ]; - - let document_schema = platform_value!({ - "type": "object", - "properties": { - "hashtag": {"type": "string", "maxLength": 63, "position": 0}, - }, - "required": ["hashtag", "$createdAt"], - "indices": Value::Array(vec![Value::Map(index_map)]), - "additionalProperties": false, - }); - let schemas = platform_value!({ "post": document_schema }); - let owner_id = Identifier::from([201u8; 32]); - factory - .create_with_value_config(owner_id, 0, schemas, None, None) - .expect("create contract") - .data_contract_owned() - } - - /// Number of documents the `trending` index returns for an exact - /// `$createdAt == bucket` lookup. - /// - /// The equality is what `IN_TIME_RANGE` resolution produces, so the query - /// is marked as such: without that provenance index selection refuses to - /// bind a bare `$createdAt` equality to a bucketed index, exactly as it - /// refuses a client-written one. - fn count_in_bucket( - drive: &Drive, - contract: &DataContract, - bucket: u64, - platform_version: &PlatformVersion, - ) -> usize { - let document_type = contract.document_type_for_name("post").expect("post"); - let query = build_created_at_query( - contract, - document_type, - bucket, - None, - created_at_resolution(document_type), - ); - query - .execute_raw_results_no_proof(drive, None, None, platform_version) - .expect("query") - .0 - .len() - } - - /// The provenance a real `IN_TIME_RANGE` resolution against this document - /// type's (single) `$createdAt` grid would have produced: the field plus - /// the exact transform, which is what pins index selection to the grid. - fn created_at_resolution( - document_type: dpp::data_contract::document_type::DocumentTypeRef, - ) -> Vec { - let transform = document_type - .indexes() - .values() - .find_map(|index| index.time_range.clone()) - .expect("the fixture declares a time-range index"); - vec![ResolvedTimeRange { transform }] - } - - /// A `$createdAt == created_at` query, optionally ANDed with - /// `hashtag == `, carrying `resolved_time_ranges` verbatim - /// so tests can drive both the resolved and the raw (empty) provenance. - fn build_created_at_query<'a>( - contract: &'a DataContract, - document_type: dpp::data_contract::document_type::DocumentTypeRef<'a>, - created_at: u64, - hashtag: Option<&str>, - resolved_time_ranges: Vec, - ) -> DriveDocumentQuery<'a> { - let mut clauses = vec![Value::Array(vec![ - Value::Text("$createdAt".to_string()), - Value::Text("==".to_string()), - Value::U64(created_at), - ])]; - if let Some(hashtag) = hashtag { - clauses.push(Value::Array(vec![ - Value::Text("hashtag".to_string()), - Value::Text("==".to_string()), - Value::Text(hashtag.to_string()), - ])); - } - let query_value = Value::Map(vec![( - Value::Text("where".to_string()), - Value::Array(clauses), - )]); - let mut query = DriveDocumentQuery::from_value( - query_value, - contract, - document_type, - &DriveConfig::default(), - PlatformVersion::latest(), - ) - .expect("build query"); - query.resolved_time_ranges = resolved_time_ranges; - query - } - - #[test] - fn time_range_insert_fans_out_to_overlapping_buckets_and_delete_removes_them() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_trending_contract(); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - - let document_type = contract.document_type_for_name("post").expect("post"); - let transform = document_type - .indexes() - .get("trending") - .expect("trending index") - .time_range - .clone() - .expect("time range transform"); - assert_eq!(transform.overlap_factor(), 3); - - // A document created at 7h+ falls into the ranges starting at 6h, 4h, 2h. - let created_at = 7 * HOUR_MS + 123_456; - let expected_buckets = transform.containing_buckets(created_at); - assert_eq!( - expected_buckets, - vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS] - ); - - let owner_bytes = fixture_bytes(1, created_at, "ibiza"); - let document = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), - owner_id: Identifier::from(owner_bytes), - properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), - created_at: Some(created_at), - ..Default::default() - }); - let document_id = document.id(); - - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &document, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(owner_bytes), - }, - contract: &contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add document"); - - // The document is queryable under each of its 3 overlapping buckets. - for bucket in &expected_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 1, - "document should be indexed under bucket {bucket}" - ); - } - // It is NOT stored under the raw timestamp (only under bucket starts)… - assert_eq!( - count_in_bucket(&drive, &contract, created_at, platform_version), - 0, - "document must be indexed under bucket starts, not the raw timestamp" - ); - // …nor under a range that does not contain it. - assert_eq!( - count_in_bucket(&drive, &contract, 0, platform_version), - 0, - "an unrelated bucket must be empty" - ); - - // Deleting the document removes every bucket entry. - drive - .delete_document_for_contract( - document_id, - &contract, - "post", - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("delete document"); - - for bucket in &expected_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 0, - "bucket {bucket} should be empty after deletion" - ); - } - } - - /// Update-path set-diff coverage: moving a timestamp between bucket sets - /// must delete the stale entries and insert the new ones, and a - /// sub-property change at an unchanged timestamp must reinsert under the - /// new suffix without duplicating entries. (Null transitions are - /// unreachable through a valid contract: the transform's system-timestamp - /// source must be a required field, so documents always carry it; the - /// walkers' null-entry handling is defense-in-depth covered by - /// `TimeRangeTransform::entry_keys_for_raw`'s unit tests.) - #[test] - fn time_range_update_moves_between_buckets_and_suffix_changes() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_trending_contract(); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - - let document_type = contract.document_type_for_name("post").expect("post"); - let transform = document_type - .indexes() - .get("trending") - .expect("trending index") - .time_range - .clone() - .expect("time range transform"); - - let first_created_at = 7 * HOUR_MS + 123_456; - let first_buckets = transform.containing_buckets(first_created_at); - assert_eq!(first_buckets, vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS]); - let second_created_at = 13 * HOUR_MS + 42; - let second_buckets = transform.containing_buckets(second_created_at); - assert_eq!( - second_buckets, - vec![12 * HOUR_MS, 10 * HOUR_MS, 8 * HOUR_MS] - ); - - let owner_bytes = fixture_bytes(1, first_created_at, "ibiza"); - let mut document = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(2, first_created_at, "ibiza")), - owner_id: Identifier::from(owner_bytes), - properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), - created_at: Some(first_created_at), - revision: Some(1), - ..Default::default() - }); - - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &document, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(owner_bytes), - }, - contract: &contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add document"); - - let update = |document: &Document, step: &str| { - drive - .update_document_for_contract( - document, - &contract, - document_type, - Some(owner_bytes), - BlockInfo::default(), - true, - None, - None, - platform_version, - None, - ) - .unwrap_or_else(|e| panic!("update document ({step}): {e:?}")); - }; - - // Move the timestamp to a disjoint bucket set: the stale entries must - // be deleted and the new ones inserted. - document.set_created_at(Some(second_created_at)); - document.set_revision(Some(2)); - update(&document, "move buckets"); - for bucket in &first_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 0, - "old bucket {bucket} should be empty after the timestamp moved" - ); - } - for bucket in &second_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 1, - "new bucket {bucket} should hold the document after the timestamp moved" - ); - } - - // A sub-property change at an unchanged timestamp reinserts under the - // new suffix without duplicating bucket entries. - document.set("hashtag", Value::Text("mykonos".to_string())); - document.set_revision(Some(3)); - update(&document, "suffix change"); - for bucket in &second_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 1, - "bucket {bucket} should still hold exactly one entry after a suffix change" - ); - } - - // Deleting the document (now holding bucket entries created by the - // update path) removes every entry. - drive - .delete_document_for_contract( - document.id(), - &contract, - "post", - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("delete document"); - for bucket in &second_buckets { - assert_eq!( - count_in_bucket(&drive, &contract, *bucket, platform_version), - 0, - "bucket {bucket} should be empty after deletion" - ); - } - } - - /// The `post` type of [`build_trending_contract`] plus two plain indexes - /// over the same fields, storing raw timestamps: - /// - /// - `byHashtag` — `(hashtag, $createdAt)`. Covers exactly the fields the - /// bucketed `trending` index covers, and sorts before it, so a search - /// that only scores field coverage — ties broken by the index map's name - /// order — always prefers it. Which of the two is correct depends - /// entirely on where the `$createdAt` value came from. - /// - `byHashtagAndAuthor` — `(hashtag, $createdAt, author)`. The only - /// index that can also serve an ordering by `author`, so it is what an - /// unpinned search falls back to for a time-range query that orders by - /// a property the bucketed index does not carry. - /// - /// Both plain indexes start with `hashtag` rather than `$createdAt`: - /// indexes sharing a first property must agree on its `timeRange` - /// transform, so a raw index can never lead with a bucketed field. - fn build_competing_index_trending_contract() -> DataContract { - let factory = - DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); - let trending_index = vec![ - ( - Value::Text("name".to_string()), - Value::Text("trending".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"$createdAt": "asc"}), - platform_value!({"hashtag": "asc"}), - ]), - ), - ( - Value::Text("timeRange".to_string()), - Value::Map(vec![ - ( - Value::Text("on".to_string()), - Value::Text("$createdAt".to_string()), - ), - ( - Value::Text("range".to_string()), - Value::U64(6 * HOUR_SECONDS), - ), - ( - Value::Text("step".to_string()), - Value::U64(2 * HOUR_SECONDS), - ), - ]), - ), - ( - Value::Text("countable".to_string()), - Value::Text("countable".to_string()), - ), - ]; - let by_hashtag_index = vec![ - ( - Value::Text("name".to_string()), - Value::Text("byHashtag".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"hashtag": "asc"}), - platform_value!({"$createdAt": "asc"}), - ]), - ), - ]; - let by_hashtag_and_author_index = vec![ - ( - Value::Text("name".to_string()), - Value::Text("byHashtagAndAuthor".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"hashtag": "asc"}), - platform_value!({"$createdAt": "asc"}), - platform_value!({"author": "asc"}), - ]), - ), - ]; - - let document_schema = platform_value!({ - "type": "object", - "properties": { - "hashtag": {"type": "string", "maxLength": 63, "position": 0}, - "author": {"type": "string", "maxLength": 63, "position": 1}, - }, - "required": ["hashtag", "$createdAt"], - "indices": Value::Array(vec![ - Value::Map(by_hashtag_index), - Value::Map(by_hashtag_and_author_index), - Value::Map(trending_index), - ]), - "additionalProperties": false, - }); - let schemas = platform_value!({ "post": document_schema }); - let owner_id = Identifier::from([201u8; 32]); - factory - .create_with_value_config(owner_id, 0, schemas, None, None) - .expect("create contract") - .data_contract_owned() - } - - /// Stores one `post` with the given timestamp and hashtag. - fn insert_post( - drive: &Drive, - contract: &DataContract, - created_at: u64, - hashtag: &str, - platform_version: &PlatformVersion, - ) { - let document_type = contract.document_type_for_name("post").expect("post"); - let owner_bytes = fixture_bytes(1, created_at, hashtag); - let document = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(2, created_at, hashtag)), - owner_id: Identifier::from(owner_bytes), - properties: BTreeMap::from([("hashtag".to_string(), Value::Text(hashtag.to_string()))]), - created_at: Some(created_at), - ..Default::default() - }); - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &document, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(owner_bytes), - }, - contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add document"); - } - - /// With two indexes covering the same fields, the value's provenance — - /// not the index map's name order — decides which index serves the query: - /// a resolved bucket start goes to the bucketed index, a raw timestamp to - /// the plain one. Getting this wrong returns a validly-proven empty result - /// in either direction, so both halves are asserted. - #[test] - fn resolved_time_range_equality_pins_the_bucketed_index_while_a_raw_one_uses_the_plain_index() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_competing_index_trending_contract(); - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - - let document_type = contract.document_type_for_name("post").expect("post"); - assert_eq!( - document_type.indexes().keys().next().map(String::as_str), - Some("byHashtag"), - "the plain index must sort first for this to reproduce the tie-break the \ - pinning fixes" - ); - - let created_at = 7 * HOUR_MS + 123_456; - let bucket = 6 * HOUR_MS; - insert_post(&drive, &contract, created_at, "ibiza", platform_version); - // A second post in the same bucket under a different hashtag: the - // hashtag equality must still narrow the result to one document. - insert_post(&drive, &contract, created_at, "mykonos", platform_version); - - let resolved = build_created_at_query( - &contract, - document_type, - bucket, - Some("ibiza"), - created_at_resolution(document_type), - ); - assert_eq!( - resolved - .find_best_index(platform_version) - .expect("the bucketed index covers the resolved query") - .name, - "trending" - ); - assert_eq!( - resolved - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("resolved query executes") - .0 - .len(), - 1, - "the resolved bucket equality must find the document stored under that bucket" - ); - - let raw = - build_created_at_query(&contract, document_type, created_at, Some("ibiza"), vec![]); - assert_eq!( - raw.find_best_index(platform_version) - .expect("the plain index covers the raw query") - .name, - "byHashtag", - "a raw `$createdAt` equality must never bind to bucket keys" - ); - assert_eq!( - raw.execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("raw query executes") - .0 - .len(), - 1, - "the plain index stores raw timestamps, so the raw equality finds the document" - ); - - // The converse of the raw case: a bucket start is not a timestamp any - // document carries, so the plain index legitimately matches nothing. - let raw_on_bucket = - build_created_at_query(&contract, document_type, bucket, Some("ibiza"), vec![]); - assert_eq!( - raw_on_bucket - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("raw query executes") - .0 - .len(), - 0 - ); - } - - /// A contract whose only index buckets `$createdAt` alone — the shape - /// where a resolved bucket equality is the query's *last* clause with no - /// left-over index properties, so cursor pagination has nothing below the - /// transformed level except the document-id terminal. - fn build_single_property_trending_contract() -> DataContract { - let factory = - DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); - let index_map = vec![ - ( - Value::Text("name".to_string()), - Value::Text("byBucket".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![platform_value!({"$createdAt": "asc"})]), - ), - ( - Value::Text("timeRange".to_string()), - Value::Map(vec![ - ( - Value::Text("on".to_string()), - Value::Text("$createdAt".to_string()), - ), - ( - Value::Text("range".to_string()), - Value::U64(6 * HOUR_SECONDS), - ), - ( - Value::Text("step".to_string()), - Value::U64(6 * HOUR_SECONDS), - ), - ]), - ), - ]; - - let document_schema = platform_value!({ - "type": "object", - "properties": { - "hashtag": {"type": "string", "maxLength": 63, "position": 0}, - }, - "required": ["hashtag", "$createdAt"], - "indices": Value::Array(vec![Value::Map(index_map)]), - "additionalProperties": false, - }); - let schemas = platform_value!({ "post": document_schema }); - let owner_id = Identifier::from([202u8; 32]); - factory - .create_with_value_config(owner_id, 0, schemas, None, None) - .expect("create contract") - .data_contract_owned() - } - - /// The transformed level stores bucket starts, so a cursor document's raw - /// timestamp must never be compared against this level's keys: an - /// included cursor created *inside* the selected bucket (07:10 in a - /// bucket starting at 06:00) orders after the bucket-start key, and - /// applying it at the transformed level suppresses the only key — a - /// validly-proven empty page while later document ids still exist in the - /// bucket. The cursor belongs to the document-id terminal instead. - #[test] - fn included_cursor_inside_the_bucket_continues_a_single_property_time_range_query() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_single_property_trending_contract(); - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - let document_type = contract.document_type_for_name("post").expect("post"); - - // Three posts inside the [06:00, 12:00) bucket, none at its start. - let bucket = 6 * HOUR_MS; - let timestamps: [(u64, &str); 3] = [ - (6 * HOUR_MS + 600_000, "early"), - (7 * HOUR_MS + 123_456, "mid"), - (8 * HOUR_MS, "late"), - ]; - for (created_at, hashtag) in timestamps { - insert_post(&drive, &contract, created_at, hashtag, platform_version); - } - - // The document-id terminal walks ids ascending; the cursor is the - // mid-bucket post, so the expected continuation is every id at or - // after it in that order. - let mut ids: Vec<[u8; 32]> = timestamps - .iter() - .map(|(created_at, hashtag)| fixture_bytes(2, *created_at, hashtag)) - .collect(); - ids.sort(); - let cursor_id = fixture_bytes(2, 7 * HOUR_MS + 123_456, "mid"); - let cursor_position = ids.iter().position(|id| *id == cursor_id).expect("cursor"); - - let mut query = build_created_at_query( - &contract, - document_type, - bucket, - None, - created_at_resolution(document_type), - ); - query.start_at = Some(cursor_id); - query.start_at_included = true; - - let (results, _, _) = query - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("query with an in-bucket cursor"); - assert_eq!( - results.len(), - ids.len() - cursor_position, - "an included in-bucket cursor must return itself and every later id in the bucket" - ); - - query.start_at_included = false; - let (results, _, _) = query - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("query with an excluded in-bucket cursor"); - assert_eq!( - results.len(), - ids.len() - cursor_position - 1, - "an excluded in-bucket cursor must return every id after it in the bucket" - ); - } - - /// One index can bucket only one field (a transform's source must be its - /// index's first property), so a query resolving two time ranges has no - /// servable shape and is refused rather than routed to whichever index - /// happens to cover the fields. - #[test] - fn two_resolved_time_ranges_are_rejected() { - let contract = build_competing_index_trending_contract(); - let document_type = contract.document_type_for_name("post").expect("post"); - let query = build_created_at_query(&contract, document_type, 6 * HOUR_MS, Some("ibiza"), { - let mut resolutions = created_at_resolution(document_type); - let mut second = resolutions[0].clone(); - second.transform.source = "hashtag".to_string(); - resolutions.push(second); - resolutions - }); - let error = query - .find_best_index(PlatformVersion::latest()) - .expect_err("two resolved time-range fields cannot be served"); - assert!( - matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), - "expected an Unsupported rejection, got {error:?}" - ); - } - - /// Ordering by a property the bucketed index does not carry must fail - /// loudly. `byHashtagAndAuthor` covers the same where fields *and* the - /// ordering, so an unpinned search would take it and match the bucket - /// start against raw timestamps — a validly-proven empty result. - #[test] - fn resolved_time_range_query_ordering_off_the_bucketed_index_errors_rather_than_falling_back() { - let contract = build_competing_index_trending_contract(); - let document_type = contract.document_type_for_name("post").expect("post"); - let query_value = Value::Map(vec![ - ( - Value::Text("where".to_string()), - Value::Array(vec![ - Value::Array(vec![ - Value::Text("hashtag".to_string()), - Value::Text("==".to_string()), - Value::Text("ibiza".to_string()), - ]), - Value::Array(vec![ - Value::Text("$createdAt".to_string()), - Value::Text("==".to_string()), - Value::U64(6 * HOUR_MS), - ]), - ]), - ), - ( - Value::Text("orderBy".to_string()), - Value::Array(vec![Value::Array(vec![ - Value::Text("author".to_string()), - Value::Text("asc".to_string()), - ])]), - ), - ]); - let mut query = DriveDocumentQuery::from_value( - query_value, - &contract, - document_type, - &DriveConfig::default(), - PlatformVersion::latest(), - ) - .expect("build query"); - // Sanity: without the provenance this query has a covering index, so - // the rejection below is the pinning talking and not a query that - // nothing could serve. - assert_eq!( - query - .find_best_index(PlatformVersion::latest()) - .expect("a plain index covers the where fields and the ordering") - .name, - "byHashtagAndAuthor" - ); - - query.resolved_time_ranges = created_at_resolution(document_type); - let error = query - .find_best_index(PlatformVersion::latest()) - .expect_err("no bucketed index covers the ordering"); - assert!( - matches!( - error, - Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) - ), - "expected a no-covering-index rejection, got {error:?}" - ); - } - - const DAY_SECONDS: u64 = 24 * HOUR_SECONDS; - const DAY_MS: u64 = 24 * HOUR_MS; - - /// A `report` document type with a **unique** - /// `(timeRange($createdAt, range = step = 1 day), author)` index — one - /// report per author per calendar day. - /// - /// `range == step` makes the windows a partition (overlap factor 1), which - /// is what lets uniqueness mean anything here, and `$createdAt` is - /// immutable so a document's bucket never moves. Both index properties are - /// required, so the terminator always takes the unique layout (the - /// reference stored AT `[0]`, with no per-document subtree). - fn build_unique_daily_report_contract() -> DataContract { - let factory = - DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); - let index_map = vec![ - ( - Value::Text("name".to_string()), - Value::Text("dailyReport".to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"$createdAt": "asc"}), - platform_value!({"author": "asc"}), - ]), - ), - (Value::Text("unique".to_string()), Value::Bool(true)), - ( - Value::Text("timeRange".to_string()), - Value::Map(vec![ - ( - Value::Text("on".to_string()), - Value::Text("$createdAt".to_string()), - ), - (Value::Text("range".to_string()), Value::U64(DAY_SECONDS)), - (Value::Text("step".to_string()), Value::U64(DAY_SECONDS)), - ]), - ), - ]; - - let document_schema = platform_value!({ - "type": "object", - "properties": { - "author": {"type": "string", "maxLength": 63, "position": 0}, - }, - "required": ["author", "$createdAt"], - "indices": Value::Array(vec![Value::Map(index_map)]), - "additionalProperties": false, - }); - let schemas = platform_value!({ "report": document_schema }); - let owner_id = Identifier::from([201u8; 32]); - factory - .create_with_value_config(owner_id, 0, schemas, None, None) - .expect("create contract") - .data_contract_owned() - } - - /// Number of `report`s stored under the exact `(bucket, author)` tuple of - /// the unique index. Carries the `IN_TIME_RANGE` provenance for the same - /// reason [`count_in_bucket`] does. - fn count_reports_for( - drive: &Drive, - contract: &DataContract, - bucket: u64, - author: &str, - platform_version: &PlatformVersion, - ) -> usize { - let document_type = contract.document_type_for_name("report").expect("report"); - let query_value = Value::Map(vec![( - Value::Text("where".to_string()), - Value::Array(vec![ - Value::Array(vec![ - Value::Text("$createdAt".to_string()), - Value::Text("==".to_string()), - Value::U64(bucket), - ]), - Value::Array(vec![ - Value::Text("author".to_string()), - Value::Text("==".to_string()), - Value::Text(author.to_string()), - ]), - ]), - )]); - let mut query = DriveDocumentQuery::from_value( - query_value, - contract, - document_type, - &DriveConfig::default(), - PlatformVersion::latest(), - ) - .expect("build query"); - query.resolved_time_ranges = created_at_resolution(document_type); - query - .execute_raw_results_no_proof(drive, None, None, platform_version) - .expect("query") - .0 - .len() - } - - /// A suffix change under a **unique** bucketed index exercises the update - /// walker's unique terminator layout end to end: the old `(bucket, author)` - /// slot must be vacated and the new one occupied. Under the non-unique - /// layout the walker would delete a doc-id key that does not exist and - /// write the reference one level too deep, leaving the old entry in place - /// and the new one unfindable. - #[test] - fn unique_time_range_index_update_moves_the_entry_between_suffixes() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_unique_daily_report_contract(); - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - - let document_type = contract.document_type_for_name("report").expect("report"); - let index = document_type - .indexes() - .get("dailyReport") - .expect("dailyReport index"); - assert!(index.unique, "the index under test must be unique"); - let transform = index - .time_range - .clone() - .expect("dailyReport buckets $createdAt"); - assert_eq!(transform.overlap_factor(), 1); - - let created_at = 100 * DAY_MS + 3 * HOUR_MS; - let bucket = *transform - .containing_buckets(created_at) - .first() - .expect("a post-origin timestamp has exactly one bucket"); - assert_eq!(bucket, 100 * DAY_MS); - - let owner_bytes = fixture_bytes(1, created_at, "alice"); - let mut document = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(2, created_at, "alice")), - owner_id: Identifier::from(owner_bytes), - properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), - created_at: Some(created_at), - revision: Some(1), - ..Default::default() - }); - - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &document, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(owner_bytes), - }, - contract: &contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add document"); - - // The insert walker's unique terminator is readable through the - // bucketed index. - assert_eq!( - count_reports_for(&drive, &contract, bucket, "alice", platform_version), - 1, - "the inserted report must be found under its (bucket, author) tuple" - ); - - // Change the suffix. `$createdAt` is untouched — it cannot change — - // so the bucket stays and only the author component of the tuple moves. - document.set("author", Value::Text("bob".to_string())); - document.set_revision(Some(2)); - drive - .update_document_for_contract( - &document, - &contract, - document_type, - Some(owner_bytes), - BlockInfo::default(), - true, - None, - None, - platform_version, - None, - ) - .expect("update document"); - - assert_eq!( - count_reports_for(&drive, &contract, bucket, "alice", platform_version), - 0, - "the old (bucket, author) slot must be vacated by the update" - ); - assert_eq!( - count_reports_for(&drive, &contract, bucket, "bob", platform_version), - 1, - "the new (bucket, author) slot must hold the document after the update" - ); - - // The vacated slot is genuinely free again: a second document may take - // it, which only holds if the update actually removed the reference - // rather than leaving a stale one behind. - let second_owner = fixture_bytes(3, created_at, "alice"); - let second = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(4, created_at, "alice")), - owner_id: Identifier::from(second_owner), - properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), - created_at: Some(created_at + HOUR_MS), - revision: Some(1), - ..Default::default() - }); - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &second, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(second_owner), - }, - contract: &contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add second document into the vacated slot"); - assert_eq!( - count_reports_for(&drive, &contract, bucket, "alice", platform_version), - 1 - ); - - // Deleting the updated document clears its slot too — the delete - // walker and the update walker must agree on where the reference is. - drive - .delete_document_for_contract( - document.id(), - &contract, - "report", - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("delete document"); - assert_eq!( - count_reports_for(&drive, &contract, bucket, "bob", platform_version), - 0, - "the updated document's slot must be empty after deletion" - ); - } - - /// The mirror case: when every index covering the query buckets the - /// field, a raw query has nowhere to go and must be refused instead of - /// silently matching a timestamp against bucket starts. - #[test] - fn raw_query_on_a_doctype_whose_only_covering_index_is_bucketed_errors() { - let contract = build_trending_contract(); - let document_type = contract.document_type_for_name("post").expect("post"); - let query = build_created_at_query( - &contract, - document_type, - 7 * HOUR_MS + 123_456, - None, - vec![], - ); - let error = query - .find_best_index(PlatformVersion::latest()) - .expect_err("a raw equality cannot be served by a bucketed index"); - assert!( - matches!( - error, - Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) - ), - "expected a no-covering-index rejection, got {error:?}" - ); - } - /// The multi-grid contract: one timestamp, two grids, sibling subtrees. - /// A 6h/2h "trending" grid and a 24h/24h "daily" grid both bucket - /// `$createdAt`; each level is keyed by the grid-qualified storage key, - /// so the two coexist — including bucket starts that are numerically - /// identical across grids (every daily start is also a trending start). - fn build_two_grid_contract() -> DataContract { - let factory = - DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); - let grid_index = |name: &str, range_seconds: u64, step_seconds: u64| { - Value::Map(vec![ - ( - Value::Text("name".to_string()), - Value::Text(name.to_string()), - ), - ( - Value::Text("properties".to_string()), - Value::Array(vec![ - platform_value!({"$createdAt": "asc"}), - platform_value!({"hashtag": "asc"}), - ]), - ), - ( - Value::Text("timeRange".to_string()), - Value::Map(vec![ - ( - Value::Text("on".to_string()), - Value::Text("$createdAt".to_string()), - ), - (Value::Text("range".to_string()), Value::U64(range_seconds)), - (Value::Text("step".to_string()), Value::U64(step_seconds)), - ]), - ), - ( - Value::Text("countable".to_string()), - Value::Text("countable".to_string()), - ), - ]) - }; - let document_schema = platform_value!({ - "type": "object", - "properties": { - "hashtag": {"type": "string", "maxLength": 63, "position": 0}, - }, - "required": ["hashtag", "$createdAt"], - "indices": Value::Array(vec![ - grid_index("trending", 6 * HOUR_SECONDS, 2 * HOUR_SECONDS), - grid_index("daily", 24 * HOUR_SECONDS, 24 * HOUR_SECONDS), - ]), - "additionalProperties": false, - }); - let schemas = platform_value!({ "post": document_schema }); - factory - .create_with_value_config(Identifier::from([202u8; 32]), 0, schemas, None, None) - .expect("a contract may bucket one timestamp with several grids") - .data_contract_owned() - } - - /// The provenance of a resolution against one named grid of a - /// multi-grid document type. - fn grid_resolution(contract: &DataContract, index_name: &str) -> Vec { - let transform = contract - .document_type_for_name("post") - .expect("post") - .indexes() - .get(index_name) - .expect("the fixture declares this index") - .time_range - .clone() - .expect("the index carries a transform"); - vec![ResolvedTimeRange { transform }] - } - - /// Two grids over `$createdAt`: a document fans out into each grid's own - /// subtree, a resolution against one grid reads only that grid's bucket - /// — even when the two grids' bucket starts are the same number — and - /// deletion empties both. The bucket start chosen here (24h) is - /// deliberately a start on BOTH grids: without grid-qualified level keys - /// the two entry sets would interleave in one keyspace and the counts - /// below would be wrong in both directions. - #[test] - fn two_grids_over_one_timestamp_write_and_read_independently() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_two_grid_contract(); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("apply contract"); - - let document_type = contract.document_type_for_name("post").expect("post"); - - // 25h10m: the daily grid buckets it at 24h; the trending grid at - // [24h, 22h, 20h]. 24h is a bucket start on BOTH grids. - let created_at = 25 * HOUR_MS + 10 * 60_000; - let shared_bucket = 24 * HOUR_MS; - - let trending = grid_resolution(&contract, "trending"); - let daily = grid_resolution(&contract, "daily"); - assert_eq!( - trending[0] - .transform - .containing_buckets(created_at) - .first() - .copied(), - Some(shared_bucket) - ); - assert_eq!( - daily[0].transform.containing_buckets(created_at), - vec![shared_bucket], - "the same numeric start on both grids is the point of this fixture" - ); - - let owner_bytes = fixture_bytes(1, created_at, "ibiza"); - let document = Document::V0(DocumentV0 { - id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), - owner_id: Identifier::from(owner_bytes), - properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), - created_at: Some(created_at), - ..Default::default() - }); - drive - .add_document_for_contract( - DocumentAndContractInfo { - owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo(( - &document, - StorageFlags::optional_default_as_cow(), - )), - owner_id: Some(owner_bytes), - }, - contract: &contract, - document_type, - }, - false, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("add document"); - - let count_for = |resolutions: &Vec, bucket: u64| -> usize { - let query = build_created_at_query( - &contract, - document_type, - bucket, - Some("ibiza"), - resolutions.clone(), - ); - query - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect("query") - .0 - .len() - }; - - // Selection pins to the resolved grid's index. - let trending_query = build_created_at_query( - &contract, - document_type, - shared_bucket, - Some("ibiza"), - trending.clone(), - ); - assert_eq!( - trending_query - .find_best_index(platform_version) - .expect("the trending grid's index serves its own resolution") - .name, - "trending" - ); - let daily_query = build_created_at_query( - &contract, - document_type, - shared_bucket, - Some("ibiza"), - daily.clone(), - ); - assert_eq!( - daily_query - .find_best_index(platform_version) - .expect("the daily grid's index serves its own resolution") - .name, - "daily" - ); - - // Each grid's subtree holds the document under the shared start, and - // the trending grid additionally holds it under its two older - // overlapping starts — which the daily grid must NOT see. - assert_eq!(count_for(&trending, shared_bucket), 1); - assert_eq!(count_for(&daily, shared_bucket), 1); - assert_eq!(count_for(&trending, 22 * HOUR_MS), 1); - assert_eq!( - count_for(&daily, 22 * HOUR_MS), - 0, - "22h is a trending fan-out entry only; leaking it into the daily \ - grid would mean the levels share a keyspace again" - ); - - // Deletion empties both grids' subtrees. - drive - .delete_document_for_contract( - document.id(), - &contract, - "post", - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("delete document"); - assert_eq!(count_for(&trending, shared_bucket), 0); - assert_eq!(count_for(&trending, 22 * HOUR_MS), 0); - assert_eq!(count_for(&daily, shared_bucket), 0); - } - - /// Resolution over a multi-grid field: the bare selector is ambiguous - /// and refused; a grid spec picks exactly the named grid; a spec no - /// index declares is refused. This is the query-language half of the - /// storage fork the previous test pins. - #[test] - fn multi_grid_resolution_requires_and_honors_a_grid_spec() { - let contract = build_two_grid_contract(); - let document_type = contract.document_type_for_name("post").expect("post"); - let now_ms = 25 * HOUR_MS; - - let error = resolve_time_range_bucket_clause( - "$createdAt", - TimeRangeSelector::Newest, - None, - document_type, - now_ms, - ) - .expect_err("two grids on the field make the bare selector ambiguous"); - assert!( - matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), - "expected the ambiguity rejection, got {error:?}" - ); - - let (clause, resolution) = resolve_time_range_bucket_clause( - "$createdAt", - TimeRangeSelector::Newest, - Some(TimeRangeGridSpec { - range_seconds: 24 * HOUR_SECONDS, - step_seconds: 24 * HOUR_SECONDS, - phase_seconds: 0, - }), - document_type, - now_ms, - ) - .expect("naming the daily grid resolves against it"); - assert_eq!(clause.value, Value::U64(24 * HOUR_MS)); - assert_eq!(resolution.transform.range_seconds, 24 * HOUR_SECONDS); - - let (clause, resolution) = resolve_time_range_bucket_clause( - "$createdAt", - TimeRangeSelector::Newest, - Some(TimeRangeGridSpec { - range_seconds: 6 * HOUR_SECONDS, - step_seconds: 2 * HOUR_SECONDS, - phase_seconds: 0, - }), - document_type, - now_ms, - ) - .expect("naming the trending grid resolves against it"); - assert_eq!( - clause.value, - Value::U64(24 * HOUR_MS), - "at 25h both grids' newest start is 24h — same number, different \ - subtree, which is exactly why provenance carries the grid" - ); - assert_eq!(resolution.transform.step_seconds, 2 * HOUR_SECONDS); - - let error = resolve_time_range_bucket_clause( - "$createdAt", - TimeRangeSelector::Newest, - Some(TimeRangeGridSpec { - range_seconds: 12 * HOUR_SECONDS, - step_seconds: 12 * HOUR_SECONDS, - phase_seconds: 0, - }), - document_type, - now_ms, - ) - .expect_err("a grid no index declares must be refused"); - assert!( - matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), - "expected the unknown-grid rejection, got {error:?}" - ); - } - - /// The multiple-`In` execution lowering picks its index directly - /// (without `find_best_index`), so it must run the shared - /// resolved-source shape guard itself: a direct caller pairing - /// fabricated provenance with an `In` clause ON the bucketed source - /// would otherwise have its raw `In` values serialized as bucket - /// keys — a validly-proven answer over arbitrary buckets. - #[test] - fn multiple_in_route_refuses_an_in_clause_on_the_bucketed_source() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = build_trending_contract(); - let document_type = contract.document_type_for_name("post").expect("post"); - - let query_value = Value::Map(vec![( - Value::Text("where".to_string()), - Value::Array(vec![ - Value::Array(vec![ - Value::Text("$createdAt".to_string()), - Value::Text("in".to_string()), - Value::Array(vec![Value::U64(2 * HOUR_MS), Value::U64(4 * HOUR_MS)]), - ]), - Value::Array(vec![ - Value::Text("hashtag".to_string()), - Value::Text("in".to_string()), - Value::Array(vec![ - Value::Text("dash".to_string()), - Value::Text("evo".to_string()), - ]), - ]), - ]), - )]); - let mut query = DriveDocumentQuery::from_value( - query_value, - &contract, - document_type, - &DriveConfig::default(), - platform_version, - ) - .expect("two In clauses are a valid protocol-version-14 query shape"); - query.resolved_time_ranges = created_at_resolution(document_type); - - let error = query - .execute_raw_results_no_proof(&drive, None, None, platform_version) - .expect_err("an In on the bucketed source must not reach bucket keys"); - assert!( - matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), - "expected the source-shape rejection, got {error:?}" - ); - } -} +mod time_range_index_e2e_tests; diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs new file mode 100644 index 00000000000..57f2c77b909 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs @@ -0,0 +1,1632 @@ +//! End-to-end coverage for time-range index fan-out: a single document is +//! indexed under every overlapping range bucket its `$createdAt` falls +//! into, those buckets are queryable by exact bucket start, and deletion +//! removes every entry. +//! +//! Also covers the other half of that contract: a bucket-start equality +//! only means "bucket" when it came from `IN_TIME_RANGE` resolution, so +//! index selection is pinned by +//! [`DriveDocumentQuery::resolved_time_ranges`] rather than left to +//! whichever index happens to cover the fields. +use crate::config::DriveConfig; +use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::{ + resolve_time_range_bucket_clause, DriveDocumentQuery, ResolvedTimeRange, TimeRangeGridSpec, + TimeRangeSelector, +}; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContractFactory; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::platform_value::{platform_value, Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; +use std::collections::BTreeMap; + +/// One hour in each of the two units these tests deal in: `*_SECONDS` +/// declares a contract's window, `*_MS` is a document timestamp, a bucket +/// start or an index key. Scaling the wrong one silently shifts the +/// buckets by a factor of a thousand, so they are kept apart by name. +const HOUR_SECONDS: u64 = 3_600; +const HOUR_MS: u64 = 3_600_000; + +/// Deterministic 32-byte fixture identifier derived from the document's +/// own fixture inputs. Identifiers here are plumbing, not test inputs: +/// fixed bytes keep a failing GroveDB fixture reproducible run-to-run and +/// avoid an OS-entropy dependency (and its unwrap) in +/// consensus-sensitive tests. `marker` separates namespaces (document id +/// vs owner) and same-timestamp siblings. +fn fixture_bytes(marker: u8, created_at: u64, tag: &str) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[0] = marker; + bytes[1..9].copy_from_slice(&created_at.to_be_bytes()); + for (i, byte) in tag.bytes().take(23).enumerate() { + bytes[9 + i] = byte; + } + bytes +} + +/// A latest-protocol `post` document type with a `(timeRange($createdAt, range=6h, +/// step=2h), hashtag)` countable index — i.e. trending hashtags over a +/// 6-hour window refreshed every 2 hours (overlap factor 3). +fn build_trending_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("trending".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(2 * HOUR_SECONDS), + ), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = Identifier::from([201u8; 32]); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() +} + +/// Number of documents the `trending` index returns for an exact +/// `$createdAt == bucket` lookup. +/// +/// The equality is what `IN_TIME_RANGE` resolution produces, so the query +/// is marked as such: without that provenance index selection refuses to +/// bind a bare `$createdAt` equality to a bucketed index, exactly as it +/// refuses a client-written one. +fn count_in_bucket( + drive: &Drive, + contract: &DataContract, + bucket: u64, + platform_version: &PlatformVersion, +) -> usize { + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query( + contract, + document_type, + bucket, + None, + created_at_resolution(document_type), + ); + query + .execute_raw_results_no_proof(drive, None, None, platform_version) + .expect("query") + .0 + .len() +} + +/// The provenance a real `IN_TIME_RANGE` resolution against this document +/// type's (single) `$createdAt` grid would have produced: the field plus +/// the exact transform, which is what pins index selection to the grid. +fn created_at_resolution(document_type: DocumentTypeRef) -> Vec { + let transform = document_type + .indexes() + .values() + .find_map(|index| index.time_range.clone()) + .expect("the fixture declares a time-range index"); + vec![ResolvedTimeRange { transform }] +} + +/// A `$createdAt == created_at` query, optionally ANDed with +/// `hashtag == `, carrying `resolved_time_ranges` verbatim +/// so tests can drive both the resolved and the raw (empty) provenance. +fn build_created_at_query<'a>( + contract: &'a DataContract, + document_type: DocumentTypeRef<'a>, + created_at: u64, + hashtag: Option<&str>, + resolved_time_ranges: Vec, +) -> DriveDocumentQuery<'a> { + let mut clauses = vec![Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(created_at), + ])]; + if let Some(hashtag) = hashtag { + clauses.push(Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("==".to_string()), + Value::Text(hashtag.to_string()), + ])); + } + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(clauses), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + query.resolved_time_ranges = resolved_time_ranges; + query +} + +#[test] +fn time_range_insert_fans_out_to_overlapping_buckets_and_delete_removes_them() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + let transform = document_type + .indexes() + .get("trending") + .expect("trending index") + .time_range + .clone() + .expect("time range transform"); + assert_eq!(transform.overlap_factor(), 3); + + // A document created at 7h+ falls into the ranges starting at 6h, 4h, 2h. + let created_at = 7 * HOUR_MS + 123_456; + let expected_buckets = transform.containing_buckets(created_at); + assert_eq!( + expected_buckets, + vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS] + ); + + let owner_bytes = fixture_bytes(1, created_at, "ibiza"); + let document = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + let document_id = document.id(); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + // The document is queryable under each of its 3 overlapping buckets. + for bucket in &expected_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "document should be indexed under bucket {bucket}" + ); + } + // It is NOT stored under the raw timestamp (only under bucket starts)… + assert_eq!( + count_in_bucket(&drive, &contract, created_at, platform_version), + 0, + "document must be indexed under bucket starts, not the raw timestamp" + ); + // …nor under a range that does not contain it. + assert_eq!( + count_in_bucket(&drive, &contract, 0, platform_version), + 0, + "an unrelated bucket must be empty" + ); + + // Deleting the document removes every bucket entry. + drive + .delete_document_for_contract( + document_id, + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + + for bucket in &expected_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "bucket {bucket} should be empty after deletion" + ); + } +} + +/// Update-path set-diff coverage: moving a timestamp between bucket sets +/// must delete the stale entries and insert the new ones, and a +/// sub-property change at an unchanged timestamp must reinsert under the +/// new suffix without duplicating entries. (Null transitions are +/// unreachable through a valid contract: the transform's system-timestamp +/// source must be a required field, so documents always carry it; the +/// walkers' null-entry handling is defense-in-depth covered by +/// `TimeRangeTransform::entry_keys_for_raw`'s unit tests.) +#[test] +fn time_range_update_moves_between_buckets_and_suffix_changes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + let transform = document_type + .indexes() + .get("trending") + .expect("trending index") + .time_range + .clone() + .expect("time range transform"); + + let first_created_at = 7 * HOUR_MS + 123_456; + let first_buckets = transform.containing_buckets(first_created_at); + assert_eq!(first_buckets, vec![6 * HOUR_MS, 4 * HOUR_MS, 2 * HOUR_MS]); + let second_created_at = 13 * HOUR_MS + 42; + let second_buckets = transform.containing_buckets(second_created_at); + assert_eq!( + second_buckets, + vec![12 * HOUR_MS, 10 * HOUR_MS, 8 * HOUR_MS] + ); + + let owner_bytes = fixture_bytes(1, first_created_at, "ibiza"); + let mut document = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(2, first_created_at, "ibiza")), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(first_created_at), + revision: Some(1), + ..Default::default() + }); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + let update = |document: &Document, step: &str| { + drive + .update_document_for_contract( + document, + &contract, + document_type, + Some(owner_bytes), + BlockInfo::default(), + true, + None, + None, + platform_version, + None, + ) + .unwrap_or_else(|e| panic!("update document ({step}): {e:?}")); + }; + + // Move the timestamp to a disjoint bucket set: the stale entries must + // be deleted and the new ones inserted. + document.set_created_at(Some(second_created_at)); + document.set_revision(Some(2)); + update(&document, "move buckets"); + for bucket in &first_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "old bucket {bucket} should be empty after the timestamp moved" + ); + } + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "new bucket {bucket} should hold the document after the timestamp moved" + ); + } + + // A sub-property change at an unchanged timestamp reinserts under the + // new suffix without duplicating bucket entries. + document.set("hashtag", Value::Text("mykonos".to_string())); + document.set_revision(Some(3)); + update(&document, "suffix change"); + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 1, + "bucket {bucket} should still hold exactly one entry after a suffix change" + ); + } + + // Deleting the document (now holding bucket entries created by the + // update path) removes every entry. + drive + .delete_document_for_contract( + document.id(), + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + for bucket in &second_buckets { + assert_eq!( + count_in_bucket(&drive, &contract, *bucket, platform_version), + 0, + "bucket {bucket} should be empty after deletion" + ); + } +} + +/// The `post` type of [`build_trending_contract`] plus two plain indexes +/// over the same fields, storing raw timestamps: +/// +/// - `byHashtag` — `(hashtag, $createdAt)`. Covers exactly the fields the +/// bucketed `trending` index covers, and sorts before it, so a search +/// that only scores field coverage — ties broken by the index map's name +/// order — always prefers it. Which of the two is correct depends +/// entirely on where the `$createdAt` value came from. +/// - `byHashtagAndAuthor` — `(hashtag, $createdAt, author)`. The only +/// index that can also serve an ordering by `author`, so it is what an +/// unpinned search falls back to for a time-range query that orders by +/// a property the bucketed index does not carry. +/// +/// Both plain indexes start with `hashtag` rather than `$createdAt`: +/// indexes sharing a first property must agree on its `timeRange` +/// transform, so a raw index can never lead with a bucketed field. +fn build_competing_index_trending_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let trending_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("trending".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(2 * HOUR_SECONDS), + ), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]; + let by_hashtag_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byHashtag".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"hashtag": "asc"}), + platform_value!({"$createdAt": "asc"}), + ]), + ), + ]; + let by_hashtag_and_author_index = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byHashtagAndAuthor".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"hashtag": "asc"}), + platform_value!({"$createdAt": "asc"}), + platform_value!({"author": "asc"}), + ]), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + "author": {"type": "string", "maxLength": 63, "position": 1}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![ + Value::Map(by_hashtag_index), + Value::Map(by_hashtag_and_author_index), + Value::Map(trending_index), + ]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = Identifier::from([201u8; 32]); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() +} + +/// Stores one `post` with the given timestamp and hashtag. +fn insert_post( + drive: &Drive, + contract: &DataContract, + created_at: u64, + hashtag: &str, + platform_version: &PlatformVersion, +) { + let document_type = contract.document_type_for_name("post").expect("post"); + let owner_bytes = fixture_bytes(1, created_at, hashtag); + let document = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(2, created_at, hashtag)), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text(hashtag.to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); +} + +/// With two indexes covering the same fields, the value's provenance — +/// not the index map's name order — decides which index serves the query: +/// a resolved bucket start goes to the bucketed index, a raw timestamp to +/// the plain one. Getting this wrong returns a validly-proven empty result +/// in either direction, so both halves are asserted. +#[test] +fn resolved_time_range_equality_pins_the_bucketed_index_while_a_raw_one_uses_the_plain_index() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_competing_index_trending_contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + assert_eq!( + document_type.indexes().keys().next().map(String::as_str), + Some("byHashtag"), + "the plain index must sort first for this to reproduce the tie-break the \ + pinning fixes" + ); + + let created_at = 7 * HOUR_MS + 123_456; + let bucket = 6 * HOUR_MS; + insert_post(&drive, &contract, created_at, "ibiza", platform_version); + // A second post in the same bucket under a different hashtag: the + // hashtag equality must still narrow the result to one document. + insert_post(&drive, &contract, created_at, "mykonos", platform_version); + + let resolved = build_created_at_query( + &contract, + document_type, + bucket, + Some("ibiza"), + created_at_resolution(document_type), + ); + assert_eq!( + resolved + .find_best_index(platform_version) + .expect("the bucketed index covers the resolved query") + .name, + "trending" + ); + assert_eq!( + resolved + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("resolved query executes") + .0 + .len(), + 1, + "the resolved bucket equality must find the document stored under that bucket" + ); + + let raw = build_created_at_query(&contract, document_type, created_at, Some("ibiza"), vec![]); + assert_eq!( + raw.find_best_index(platform_version) + .expect("the plain index covers the raw query") + .name, + "byHashtag", + "a raw `$createdAt` equality must never bind to bucket keys" + ); + assert_eq!( + raw.execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("raw query executes") + .0 + .len(), + 1, + "the plain index stores raw timestamps, so the raw equality finds the document" + ); + + // The converse of the raw case: a bucket start is not a timestamp any + // document carries, so the plain index legitimately matches nothing. + let raw_on_bucket = + build_created_at_query(&contract, document_type, bucket, Some("ibiza"), vec![]); + assert_eq!( + raw_on_bucket + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("raw query executes") + .0 + .len(), + 0 + ); +} + +/// A contract whose only index buckets `$createdAt` alone — the shape +/// where a resolved bucket equality is the query's *last* clause with no +/// left-over index properties, so cursor pagination has nothing below the +/// transformed level except the document-id terminal. +/// +/// `unique` flips the index's uniqueness: the grid already satisfies the +/// unique shape (`range == step` on `$createdAt`), and the unique terminal +/// layout stores the reference AT the value tree rather than under an id +/// terminal — the other arm the in-bucket cursor rule must handle. +fn build_single_property_trending_contract(unique: bool) -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("byBucket".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![platform_value!({"$createdAt": "asc"})]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + ( + Value::Text("range".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ( + Value::Text("step".to_string()), + Value::U64(6 * HOUR_SECONDS), + ), + ]), + ), + (Value::Text("unique".to_string()), Value::Bool(unique)), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + let owner_id = Identifier::from([202u8; 32]); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() +} + +/// The ids of raw query results, in returned order. +fn result_ids( + results: &[Vec], + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, +) -> Vec<[u8; 32]> { + results + .iter() + .map(|serialized| { + Document::from_bytes(serialized, document_type, platform_version) + .expect("deserialize result") + .id() + .to_buffer() + }) + .collect() +} + +/// The transformed level stores bucket starts, so a cursor document's raw +/// timestamp must never be compared against this level's keys: an +/// included cursor created *inside* the selected bucket (07:10 in a +/// bucket starting at 06:00) orders after the bucket-start key, and +/// applying it at the transformed level suppresses the only key — a +/// validly-proven empty page while later document ids still exist in the +/// bucket. The cursor belongs to the document-id terminal instead. +#[test] +fn included_cursor_inside_the_bucket_continues_a_single_property_time_range_query() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_single_property_trending_contract(false); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + let document_type = contract.document_type_for_name("post").expect("post"); + + // Three posts inside the [06:00, 12:00) bucket, none at its start. + let bucket = 6 * HOUR_MS; + let timestamps: [(u64, &str); 3] = [ + (6 * HOUR_MS + 600_000, "early"), + (7 * HOUR_MS + 123_456, "mid"), + (8 * HOUR_MS, "late"), + ]; + for (created_at, hashtag) in timestamps { + insert_post(&drive, &contract, created_at, hashtag, platform_version); + } + + // The document-id terminal walks ids ascending; the cursor is the + // mid-bucket post, so the expected continuation is every id at or + // after it in that order. + let mut ids: Vec<[u8; 32]> = timestamps + .iter() + .map(|(created_at, hashtag)| fixture_bytes(2, *created_at, hashtag)) + .collect(); + ids.sort(); + let cursor_id = fixture_bytes(2, 7 * HOUR_MS + 123_456, "mid"); + let cursor_position = ids.iter().position(|id| *id == cursor_id).expect("cursor"); + + let mut query = build_created_at_query( + &contract, + document_type, + bucket, + None, + created_at_resolution(document_type), + ); + query.start_at = Some(cursor_id); + query.start_at_included = true; + + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query with an in-bucket cursor"); + assert_eq!( + result_ids(&results, document_type, platform_version), + &ids[cursor_position..], + "an included in-bucket cursor must return itself and every later id \ + in the bucket, in id order" + ); + + query.start_at_included = false; + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query with an excluded in-bucket cursor"); + assert_eq!( + result_ids(&results, document_type, platform_version), + &ids[cursor_position + 1..], + "an excluded in-bucket cursor must return every id after it in the \ + bucket, in id order" + ); +} + +/// The unique arm of the in-bucket cursor rule: a unique single-property +/// time-range index (`range == step` on `$createdAt`) stores the bucket's +/// sole reference AT the value tree, with no document-id terminal below +/// it. An included cursor must retain the bucket's one document; an +/// excluded cursor must produce an empty page — never an error, and never +/// the full page again. +#[test] +fn in_bucket_cursor_on_a_unique_single_property_time_range_index() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_single_property_trending_contract(true); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + let document_type = contract.document_type_for_name("post").expect("post"); + + // One document in the [06:00, 12:00) bucket — the most a unique + // single-property grid admits. + let created_at = 7 * HOUR_MS + 123_456; + insert_post(&drive, &contract, created_at, "solo", platform_version); + let document_id = fixture_bytes(2, created_at, "solo"); + let bucket = 6 * HOUR_MS; + + let mut query = build_created_at_query( + &contract, + document_type, + bucket, + None, + created_at_resolution(document_type), + ); + query.start_at = Some(document_id); + query.start_at_included = true; + + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("included cursor on the unique layout"); + assert_eq!( + result_ids(&results, document_type, platform_version), + vec![document_id], + "an included cursor must retain the bucket's sole document" + ); + + query.start_at_included = false; + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("excluded cursor on the unique layout"); + assert!( + results.is_empty(), + "an excluded cursor must empty the page — the bucket holds exactly the cursor" + ); +} + +/// One index can bucket only one field (a transform's source must be its +/// index's first property), so a query resolving two time ranges has no +/// servable shape and is refused rather than routed to whichever index +/// happens to cover the fields. +#[test] +fn two_resolved_time_ranges_are_rejected() { + let contract = build_competing_index_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query(&contract, document_type, 6 * HOUR_MS, Some("ibiza"), { + let mut resolutions = created_at_resolution(document_type); + let mut second = resolutions[0].clone(); + second.transform.source = "hashtag".to_string(); + resolutions.push(second); + resolutions + }); + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("two resolved time-range fields cannot be served"); + assert!( + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), + "expected an Unsupported rejection, got {error:?}" + ); +} + +/// Ordering by a property the bucketed index does not carry must fail +/// loudly. `byHashtagAndAuthor` covers the same where fields *and* the +/// ordering, so an unpinned search would take it and match the bucket +/// start against raw timestamps — a validly-proven empty result. +#[test] +fn resolved_time_range_query_ordering_off_the_bucketed_index_errors_rather_than_falling_back() { + let contract = build_competing_index_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query_value = Value::Map(vec![ + ( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("==".to_string()), + Value::Text("ibiza".to_string()), + ]), + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(6 * HOUR_MS), + ]), + ]), + ), + ( + Value::Text("orderBy".to_string()), + Value::Array(vec![Value::Array(vec![ + Value::Text("author".to_string()), + Value::Text("asc".to_string()), + ])]), + ), + ]); + let mut query = DriveDocumentQuery::from_value( + query_value, + &contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + // Sanity: without the provenance this query has a covering index, so + // the rejection below is the pinning talking and not a query that + // nothing could serve. + assert_eq!( + query + .find_best_index(PlatformVersion::latest()) + .expect("a plain index covers the where fields and the ordering") + .name, + "byHashtagAndAuthor" + ); + + query.resolved_time_ranges = created_at_resolution(document_type); + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("no bucketed index covers the ordering"); + assert!( + matches!( + error, + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) + ), + "expected a no-covering-index rejection, got {error:?}" + ); +} + +const DAY_SECONDS: u64 = 24 * HOUR_SECONDS; +const DAY_MS: u64 = 24 * HOUR_MS; + +/// A `report` document type with a **unique** +/// `(timeRange($createdAt, range = step = 1 day), author)` index — one +/// report per author per calendar day. +/// +/// `range == step` makes the windows a partition (overlap factor 1), which +/// is what lets uniqueness mean anything here, and `$createdAt` is +/// immutable so a document's bucket never moves. Both index properties are +/// required, so the terminator always takes the unique layout (the +/// reference stored AT `[0]`, with no per-document subtree). +fn build_unique_daily_report_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let index_map = vec![ + ( + Value::Text("name".to_string()), + Value::Text("dailyReport".to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"author": "asc"}), + ]), + ), + (Value::Text("unique".to_string()), Value::Bool(true)), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(DAY_SECONDS)), + (Value::Text("step".to_string()), Value::U64(DAY_SECONDS)), + ]), + ), + ]; + + let document_schema = platform_value!({ + "type": "object", + "properties": { + "author": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["author", "$createdAt"], + "indices": Value::Array(vec![Value::Map(index_map)]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "report": document_schema }); + let owner_id = Identifier::from([201u8; 32]); + factory + .create_with_value_config(owner_id, 0, schemas, None, None) + .expect("create contract") + .data_contract_owned() +} + +/// Number of `report`s stored under the exact `(bucket, author)` tuple of +/// the unique index. Carries the `IN_TIME_RANGE` provenance for the same +/// reason [`count_in_bucket`] does. +fn count_reports_for( + drive: &Drive, + contract: &DataContract, + bucket: u64, + author: &str, + platform_version: &PlatformVersion, +) -> usize { + let document_type = contract.document_type_for_name("report").expect("report"); + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("==".to_string()), + Value::U64(bucket), + ]), + Value::Array(vec![ + Value::Text("author".to_string()), + Value::Text("==".to_string()), + Value::Text(author.to_string()), + ]), + ]), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + contract, + document_type, + &DriveConfig::default(), + PlatformVersion::latest(), + ) + .expect("build query"); + query.resolved_time_ranges = created_at_resolution(document_type); + query + .execute_raw_results_no_proof(drive, None, None, platform_version) + .expect("query") + .0 + .len() +} + +/// A suffix change under a **unique** bucketed index exercises the update +/// walker's unique terminator layout end to end: the old `(bucket, author)` +/// slot must be vacated and the new one occupied. Under the non-unique +/// layout the walker would delete a doc-id key that does not exist and +/// write the reference one level too deep, leaving the old entry in place +/// and the new one unfindable. +#[test] +fn unique_time_range_index_update_moves_the_entry_between_suffixes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_unique_daily_report_contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("report").expect("report"); + let index = document_type + .indexes() + .get("dailyReport") + .expect("dailyReport index"); + assert!(index.unique, "the index under test must be unique"); + let transform = index + .time_range + .clone() + .expect("dailyReport buckets $createdAt"); + assert_eq!(transform.overlap_factor(), 1); + + let created_at = 100 * DAY_MS + 3 * HOUR_MS; + let bucket = *transform + .containing_buckets(created_at) + .first() + .expect("a post-origin timestamp has exactly one bucket"); + assert_eq!(bucket, 100 * DAY_MS); + + let owner_bytes = fixture_bytes(1, created_at, "alice"); + let mut document = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(2, created_at, "alice")), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), + created_at: Some(created_at), + revision: Some(1), + ..Default::default() + }); + + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + // The insert walker's unique terminator is readable through the + // bucketed index. + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 1, + "the inserted report must be found under its (bucket, author) tuple" + ); + + // Change the suffix. `$createdAt` is untouched — it cannot change — + // so the bucket stays and only the author component of the tuple moves. + document.set("author", Value::Text("bob".to_string())); + document.set_revision(Some(2)); + drive + .update_document_for_contract( + &document, + &contract, + document_type, + Some(owner_bytes), + BlockInfo::default(), + true, + None, + None, + platform_version, + None, + ) + .expect("update document"); + + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 0, + "the old (bucket, author) slot must be vacated by the update" + ); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "bob", platform_version), + 1, + "the new (bucket, author) slot must hold the document after the update" + ); + + // The vacated slot is genuinely free again: a second document may take + // it, which only holds if the update actually removed the reference + // rather than leaving a stale one behind. + let second_owner = fixture_bytes(3, created_at, "alice"); + let second = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(4, created_at, "alice")), + owner_id: Identifier::from(second_owner), + properties: BTreeMap::from([("author".to_string(), Value::Text("alice".to_string()))]), + created_at: Some(created_at + HOUR_MS), + revision: Some(1), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &second, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(second_owner), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add second document into the vacated slot"); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "alice", platform_version), + 1 + ); + + // Deleting the updated document clears its slot too — the delete + // walker and the update walker must agree on where the reference is. + drive + .delete_document_for_contract( + document.id(), + &contract, + "report", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + assert_eq!( + count_reports_for(&drive, &contract, bucket, "bob", platform_version), + 0, + "the updated document's slot must be empty after deletion" + ); +} + +/// The mirror case: when every index covering the query buckets the +/// field, a raw query has nowhere to go and must be refused instead of +/// silently matching a timestamp against bucket starts. +#[test] +fn raw_query_on_a_doctype_whose_only_covering_index_is_bucketed_errors() { + let contract = build_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let query = build_created_at_query( + &contract, + document_type, + 7 * HOUR_MS + 123_456, + None, + vec![], + ); + let error = query + .find_best_index(PlatformVersion::latest()) + .expect_err("a raw equality cannot be served by a bucketed index"); + assert!( + matches!( + error, + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(_)) + ), + "expected a no-covering-index rejection, got {error:?}" + ); +} +/// The multi-grid contract: one timestamp, two grids, sibling subtrees. +/// A 6h/2h "trending" grid and a 24h/24h "daily" grid both bucket +/// `$createdAt`; each level is keyed by the grid-qualified storage key, +/// so the two coexist — including bucket starts that are numerically +/// identical across grids (every daily start is also a trending start). +fn build_two_grid_contract() -> DataContract { + let factory = + DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); + let grid_index = |name: &str, range_seconds: u64, step_seconds: u64| { + Value::Map(vec![ + ( + Value::Text("name".to_string()), + Value::Text(name.to_string()), + ), + ( + Value::Text("properties".to_string()), + Value::Array(vec![ + platform_value!({"$createdAt": "asc"}), + platform_value!({"hashtag": "asc"}), + ]), + ), + ( + Value::Text("timeRange".to_string()), + Value::Map(vec![ + ( + Value::Text("on".to_string()), + Value::Text("$createdAt".to_string()), + ), + (Value::Text("range".to_string()), Value::U64(range_seconds)), + (Value::Text("step".to_string()), Value::U64(step_seconds)), + ]), + ), + ( + Value::Text("countable".to_string()), + Value::Text("countable".to_string()), + ), + ]) + }; + let document_schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": {"type": "string", "maxLength": 63, "position": 0}, + }, + "required": ["hashtag", "$createdAt"], + "indices": Value::Array(vec![ + grid_index("trending", 6 * HOUR_SECONDS, 2 * HOUR_SECONDS), + grid_index("daily", 24 * HOUR_SECONDS, 24 * HOUR_SECONDS), + ]), + "additionalProperties": false, + }); + let schemas = platform_value!({ "post": document_schema }); + factory + .create_with_value_config(Identifier::from([202u8; 32]), 0, schemas, None, None) + .expect("a contract may bucket one timestamp with several grids") + .data_contract_owned() +} + +/// The provenance of a resolution against one named grid of a +/// multi-grid document type. +fn grid_resolution(contract: &DataContract, index_name: &str) -> Vec { + let transform = contract + .document_type_for_name("post") + .expect("post") + .indexes() + .get(index_name) + .expect("the fixture declares this index") + .time_range + .clone() + .expect("the index carries a transform"); + vec![ResolvedTimeRange { transform }] +} + +/// Two grids over `$createdAt`: a document fans out into each grid's own +/// subtree, a resolution against one grid reads only that grid's bucket +/// — even when the two grids' bucket starts are the same number — and +/// deletion empties both. The bucket start chosen here (24h) is +/// deliberately a start on BOTH grids: without grid-qualified level keys +/// the two entry sets would interleave in one keyspace and the counts +/// below would be wrong in both directions. +#[test] +fn two_grids_over_one_timestamp_write_and_read_independently() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_two_grid_contract(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = contract.document_type_for_name("post").expect("post"); + + // 25h10m: the daily grid buckets it at 24h; the trending grid at + // [24h, 22h, 20h]. 24h is a bucket start on BOTH grids. + let created_at = 25 * HOUR_MS + 10 * 60_000; + let shared_bucket = 24 * HOUR_MS; + + let trending = grid_resolution(&contract, "trending"); + let daily = grid_resolution(&contract, "daily"); + assert_eq!( + trending[0] + .transform + .containing_buckets(created_at) + .first() + .copied(), + Some(shared_bucket) + ); + assert_eq!( + daily[0].transform.containing_buckets(created_at), + vec![shared_bucket], + "the same numeric start on both grids is the point of this fixture" + ); + + let owner_bytes = fixture_bytes(1, created_at, "ibiza"); + let document = Document::V0(DocumentV0 { + id: Identifier::from(fixture_bytes(2, created_at, "ibiza")), + owner_id: Identifier::from(owner_bytes), + properties: BTreeMap::from([("hashtag".to_string(), Value::Text("ibiza".to_string()))]), + created_at: Some(created_at), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_bytes), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("add document"); + + let count_for = |resolutions: &Vec, bucket: u64| -> usize { + let query = build_created_at_query( + &contract, + document_type, + bucket, + Some("ibiza"), + resolutions.clone(), + ); + query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("query") + .0 + .len() + }; + + // Selection pins to the resolved grid's index. + let trending_query = build_created_at_query( + &contract, + document_type, + shared_bucket, + Some("ibiza"), + trending.clone(), + ); + assert_eq!( + trending_query + .find_best_index(platform_version) + .expect("the trending grid's index serves its own resolution") + .name, + "trending" + ); + let daily_query = build_created_at_query( + &contract, + document_type, + shared_bucket, + Some("ibiza"), + daily.clone(), + ); + assert_eq!( + daily_query + .find_best_index(platform_version) + .expect("the daily grid's index serves its own resolution") + .name, + "daily" + ); + + // Each grid's subtree holds the document under the shared start, and + // the trending grid additionally holds it under its two older + // overlapping starts — which the daily grid must NOT see. + assert_eq!(count_for(&trending, shared_bucket), 1); + assert_eq!(count_for(&daily, shared_bucket), 1); + assert_eq!(count_for(&trending, 22 * HOUR_MS), 1); + assert_eq!( + count_for(&daily, 22 * HOUR_MS), + 0, + "22h is a trending fan-out entry only; leaking it into the daily \ + grid would mean the levels share a keyspace again" + ); + + // Deletion empties both grids' subtrees. + drive + .delete_document_for_contract( + document.id(), + &contract, + "post", + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("delete document"); + assert_eq!(count_for(&trending, shared_bucket), 0); + assert_eq!(count_for(&trending, 22 * HOUR_MS), 0); + assert_eq!(count_for(&daily, shared_bucket), 0); +} + +/// Resolution over a multi-grid field: the bare selector is ambiguous +/// and refused; a grid spec picks exactly the named grid; a spec no +/// index declares is refused. This is the query-language half of the +/// storage fork the previous test pins. +#[test] +fn multi_grid_resolution_requires_and_honors_a_grid_spec() { + let contract = build_two_grid_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + let now_ms = 25 * HOUR_MS; + + let error = resolve_time_range_bucket_clause( + "$createdAt", + TimeRangeSelector::Newest, + None, + document_type, + now_ms, + ) + .expect_err("two grids on the field make the bare selector ambiguous"); + assert!( + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), + "expected the ambiguity rejection, got {error:?}" + ); + + let (clause, resolution) = resolve_time_range_bucket_clause( + "$createdAt", + TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 24 * HOUR_SECONDS, + step_seconds: 24 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect("naming the daily grid resolves against it"); + assert_eq!(clause.value, Value::U64(24 * HOUR_MS)); + assert_eq!(resolution.transform.range_seconds, 24 * HOUR_SECONDS); + + let (clause, resolution) = resolve_time_range_bucket_clause( + "$createdAt", + TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 6 * HOUR_SECONDS, + step_seconds: 2 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect("naming the trending grid resolves against it"); + assert_eq!( + clause.value, + Value::U64(24 * HOUR_MS), + "at 25h both grids' newest start is 24h — same number, different \ + subtree, which is exactly why provenance carries the grid" + ); + assert_eq!(resolution.transform.step_seconds, 2 * HOUR_SECONDS); + + let error = resolve_time_range_bucket_clause( + "$createdAt", + TimeRangeSelector::Newest, + Some(TimeRangeGridSpec { + range_seconds: 12 * HOUR_SECONDS, + step_seconds: 12 * HOUR_SECONDS, + phase_seconds: 0, + }), + document_type, + now_ms, + ) + .expect_err("a grid no index declares must be refused"); + assert!( + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), + "expected the unknown-grid rejection, got {error:?}" + ); +} + +/// The multiple-`In` execution lowering picks its index directly +/// (without `find_best_index`), so it must run the shared +/// resolved-source shape guard itself: a direct caller pairing +/// fabricated provenance with an `In` clause ON the bucketed source +/// would otherwise have its raw `In` values serialized as bucket +/// keys — a validly-proven answer over arbitrary buckets. +#[test] +fn multiple_in_route_refuses_an_in_clause_on_the_bucketed_source() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_trending_contract(); + let document_type = contract.document_type_for_name("post").expect("post"); + + let query_value = Value::Map(vec![( + Value::Text("where".to_string()), + Value::Array(vec![ + Value::Array(vec![ + Value::Text("$createdAt".to_string()), + Value::Text("in".to_string()), + Value::Array(vec![Value::U64(2 * HOUR_MS), Value::U64(4 * HOUR_MS)]), + ]), + Value::Array(vec![ + Value::Text("hashtag".to_string()), + Value::Text("in".to_string()), + Value::Array(vec![ + Value::Text("dash".to_string()), + Value::Text("evo".to_string()), + ]), + ]), + ]), + )]); + let mut query = DriveDocumentQuery::from_value( + query_value, + &contract, + document_type, + &DriveConfig::default(), + platform_version, + ) + .expect("two In clauses are a valid protocol-version-14 query shape"); + query.resolved_time_ranges = created_at_resolution(document_type); + + let error = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect_err("an In on the bucketed source must not reach bucket keys"); + assert!( + matches!(error, Error::Query(QuerySyntaxError::Unsupported(_))), + "expected the source-shape rejection, got {error:?}" + ); +} From 36d8f28e358229be0aea53caf83730726ebc6ee3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 21:12:26 +0200 Subject: [PATCH 19/20] docs(drive): drop the last references to the deleted cross-index grid rule Two comments still justified behavior by the pre-multi-grid "indexes sharing a first property must agree on the transform" validation, which the grid-qualified level keys deleted. The update walker's insertion cache is safe because it keys on the full qualified path; a plain index may lead with a bucketed field, and safety comes from provenance-pinned index selection. Co-Authored-By: Claude Fable 5 --- .../time_range_index_e2e_tests.rs | 9 ++++++--- .../update_document_for_contract_operations/v1/mod.rs | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs index 57f2c77b909..d26fee42864 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs @@ -464,9 +464,12 @@ fn time_range_update_moves_between_buckets_and_suffix_changes() { /// unpinned search falls back to for a time-range query that orders by /// a property the bucketed index does not carry. /// -/// Both plain indexes start with `hashtag` rather than `$createdAt`: -/// indexes sharing a first property must agree on its `timeRange` -/// transform, so a raw index can never lead with a bucketed field. +/// Both plain indexes start with `hashtag` rather than `$createdAt` so +/// their `$createdAt` entries hold raw timestamps at a non-leading +/// position — the competing-coverage shape these tests need. (A plain +/// index MAY lead with a bucketed field: grids fork into sibling +/// subtrees via grid-qualified level keys, so no cross-index agreement +/// rule exists; safety comes from provenance-pinned index selection.) fn build_competing_index_trending_contract() -> DataContract { let factory = DataContractFactory::new(PlatformVersion::latest().protocol_version).expect("factory"); diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index 56f0e005787..45771bede30 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -1065,8 +1065,11 @@ impl Drive { // post-demotion tree types and zero-contribution wrappers the // insert walkers use — then store the reference. The insertion // cache prevents duplicate empty-tree operations when several - // indexes share the first property (and therefore, by the - // cross-index validation, the identical transform and buckets). + // indexes produce the same qualified path. Indexes sharing a + // first property need NOT share a transform (grids fork into + // sibling subtrees via their grid-qualified level keys), which + // is exactly why the cache keys on the full qualified path + // rather than the property name. let mut path: Vec> = base_index_path.to_vec(); let mut qualified_path = path.clone(); qualified_path.push(entry_key.clone()); From 1cb58ee7f196a7d0b4ca5c25c603567b2e3652ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Aug 2026 22:11:13 +0200 Subject: [PATCH 20/20] test(drive-abci): re-sign tampered metadata so the negatives pin the proof path The COUNT/SUM/AVG tampered-metadata tests altered time_ms after signing and accepted InvalidSignature, so they could not distinguish which layer rejected. They now re-sign the altered metadata with the fixture quorum key and require a GroveDB/Drive proof-path rejection: the verifier resolves the selector one step later, reconstructs the next bucket's path query, and refuses the stale bucket's proof. (Verification reconstructs the proof path before the signature check; signature binding stays pinned by the trust-boundary tests.) Co-Authored-By: Claude Fable 5 --- .../src/query/document_query/v1/tests.rs | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 8d410a2bc73..a1ddb2363ea 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -4550,6 +4550,28 @@ mod time_range_proof_verification { (signed, mtd, provider) } + /// Re-sign the handler's proof over *altered* metadata with the fixture + /// quorum key. The signature then verifies, so a rejection can only come + /// from the proof path itself: the verifier resolves the selector from + /// the altered time into the NEXT bucket, reconstructs that bucket's + /// path query, and the GroveDB proof over the original bucket cannot + /// satisfy it. Without the re-signing, the negative tests could pass at + /// signature verification and never reach that property. + fn resign_over( + platform: &TempPlatform, + proof: &Proof, + mtd: &ResponseMetadata, + platform_version: &PlatformVersion, + ) -> Proof { + signed_proof( + proof.grovedb_proof.clone(), + &root_hash(&platform.drive, platform_version), + mtd, + &quorum_secret_key(), + QUORUM_HASH, + ) + } + /// The bucket start the transform puts `time_ms` in, computed straight /// off the contract's declared window rather than off the constants /// above — so a fixture edit that moves the grid cannot leave the @@ -4990,15 +5012,17 @@ mod time_range_proof_verification { tampered } - fn assert_proof_or_signature_rejection(error: ProofVerifierError) { + /// The rejection must come from proof reconstruction (GroveDB/Drive) — + /// the caller re-signed the altered metadata, so `InvalidSignature` + /// would mean the deeper property (resolve-later-bucket → mismatched + /// proof path) was never exercised. + fn assert_proof_path_rejection(error: ProofVerifierError) { assert!( matches!( error, - ProofVerifierError::InvalidSignature { .. } - | ProofVerifierError::GroveDBError { .. } - | ProofVerifierError::DriveError { .. } + ProofVerifierError::GroveDBError { .. } | ProofVerifierError::DriveError { .. } ), - "the rejection must be the proof or the signature binding, got: {error:?}" + "the rejection must come from the proof path, got: {error:?}" ); } @@ -5042,16 +5066,25 @@ mod time_range_proof_verification { let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); let tampered = one_step_later(&contract, BUCKETED_INDEX, &mtd); + // Re-sign the altered metadata so the signature is valid — + // verification must then resolve the selector one step later, + // reconstruct the NEXT bucket's path query, and reject the + // original bucket's GroveDB proof. (Verification reconstructs the + // proof path BEFORE checking the signature, so an unsigned + // alteration would hit the same rejection without proving the + // signature binds anything; the binding itself is pinned by the + // trust-boundary tests above.) + let resigned = resign_over(&platform, &proof, &tampered, version); let query = sdk_query(&contract, "ibiza", SelectProjection::count_star()); let error = >::maybe_from_proof_with_metadata( query, - signed_response(proof, &tampered), + signed_response(resigned, &tampered), Network::Testnet, version, &provider, ) - .expect_err("an altered signed time must not yield a verified count"); - assert_proof_or_signature_rejection(error); + .expect_err("a validly re-signed later time must not verify the stale bucket's proof"); + assert_proof_path_rejection(error); } #[test] @@ -5096,17 +5129,18 @@ mod time_range_proof_verification { }; let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); let tampered = one_step_later(&contract, SUMMABLE_INDEX, &mtd); + let resigned = resign_over(&platform, &proof, &tampered, version); let query = sdk_query(&contract, "ibiza", SelectProjection::sum("likes")); let error = >::maybe_from_proof_with_metadata( query, - signed_response(proof, &tampered), + signed_response(resigned, &tampered), Network::Testnet, version, &provider, ) - .expect_err("an altered signed time must not yield a verified sum"); - assert_proof_or_signature_rejection(error); + .expect_err("a validly re-signed later time must not verify the stale bucket's proof"); + assert_proof_path_rejection(error); } #[test] @@ -5152,18 +5186,19 @@ mod time_range_proof_verification { }; let (proof, mtd, provider) = prove_and_sign(&platform, &state, request, version); let tampered = one_step_later(&contract, SUMMABLE_INDEX, &mtd); + let resigned = resign_over(&platform, &proof, &tampered, version); let query = sdk_query(&contract, "ibiza", SelectProjection::avg("likes")); let error = >::maybe_from_proof_with_metadata( query, - signed_response(proof, &tampered), + signed_response(resigned, &tampered), Network::Testnet, version, &provider, ) - .expect_err("an altered signed time must not yield a verified average"); - assert_proof_or_signature_rejection(error); + .expect_err("a validly re-signed later time must not verify the stale bucket's proof"); + assert_proof_path_rejection(error); } // ----- multiple grids over one timestamp ------------------------------