Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions book/src/drive/index-only-document-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,26 @@ refersTo-typed identifier property (identity, contract, token, permanent
document) — is the member key, sitting exactly where a normal non-unique
index keys by document id; the element is an `Item` instead of a
`Reference` because there is nothing to point at. The `0` storage marker,
value-tree types, and the count/ranked tree derivation are byte-identical
to the ordinary non-unique layout, which is what lets the protocol v14
ranked machinery (see [Document Ranked Trees](./document-ranked-trees.md))
serve index-only types unchanged: "the five most-liked posts in `#dash`"
is an O(log n + k) read with an O(log n + k) proof, and Items count in
value-tree types, and the count/sum/ranked tree derivation are
byte-identical to the ordinary non-unique layout, which is what lets the
protocol v14 ranked machinery (see
[Document Ranked Trees](./document-ranked-trees.md)) serve index-only
types unchanged: "the five most-liked posts in `#dash`" is an
O(log n + k) read with an O(log n + k) proof, and Items count in
count/ranked trees exactly as References do.

The **sum axes** compose the same way: a `summable: "<prop>"` index
stores `ItemWithSumItem(<row commitment>, <amount>)` terminals — the same
commitment payload, plus the summed property's value — so entries
contribute to ancestor sum trees exactly as stored types'
`ReferenceWithSumItem` references do ("total tipped to this post", "top
posts by total tipped" via `rankedSummable`). The doctype-level summable
cross-checks (one canonical summed property, i64-safe integer type,
`required` membership) apply unchanged, and on delete grovedb reads the
amount off the stored element and propagates the subtraction — the
falsified-amount case dies on the commitment probe first, since the
amount is one of the committed properties.

**Governing principle: only what is in the indexes exists and is
recoverable.** Prefix property values live in the path, the terminal id in
the member key, `$ownerId` and `$createdAt` wherever an index carries them.
Expand Down Expand Up @@ -72,7 +85,7 @@ aggregate keywords follow:
| terminal is `$ownerId` or a single-id refersTo property | the member key must alone be a referable entity id (`identityPublicKey` is compound and rejected) |
| indexed `$createdAt` requires `$createdAt` in `required` | creation only assigns timestamps for required system times |
| `documentsMutable: false`, no transfers/trading/history/transient | no stored row, no revision |
| non-unique, non-contested, `nullSearchable` default, no `timeRange`, count axes only | v1 scope; sum axes and buckets are follow-ups |
| non-unique, non-contested, `nullSearchable` default, no `timeRange` | v1 scope; buckets are a follow-up |

`indexOnly` and the index set (terminals included) are immutable across
contract updates — a later-added index could never be backfilled.
Expand Down Expand Up @@ -121,8 +134,9 @@ by the presence of the entry its values produce under the **proof index**
(the first `$ownerId`-bearing index not involving `$createdAt` — contract
admission guarantees one exists) and a delete by its absence, with the
proved entry's payload checked against the transition-derived row
commitment; prover and verifier build the same single-entry path query
from the transition. The outcome is always `AffectedState`, never
commitment (and, when the proof index is summable, the proved sum
contribution against the created document's amount); prover and verifier
build the same single-entry path query from the transition. The outcome is always `AffectedState`, never
`ExecutionProved`: the commitment carries neither id, entropy nor nonce,
so a snapshot cannot bind one specific transition's execution.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2047,16 +2047,13 @@ pub(super) fn apply_index_only(
index_name, name,
)));
}
if index.summable.is_some() || index.ranked_summable || index.ranked_averageable {
return Err(structure_error(format!(
"index \"{}\" on indexOnly document type \"{}\" cannot use the sum axes \
(summable / rangeSummable / rankedSummable / rankedAverageable / the \
averageable sugar): indexOnly terminals are plain Items carrying no sum \
contribution; only the count axes (countable / rangeCountable / \
rankedCountable) are supported",
index_name, name,
)));
}
// The sum axes (summable / rangeSummable / rankedSummable /
// rankedAverageable / the averageable sugar) are admitted: a
// summable index's terminal entry is an
// `ItemWithSumItem(commitment, amount)` carrying the summed
// property's value, and the doctype-level summable cross-checks
// (canonical property, i64-safe integer type, `required`
// membership) run for every doctype, indexOnly included.

let terminal = index.terminal.as_deref().expect("normalized to Some above");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,27 @@ fn rejects_null_searchable_false() {
}

#[test]
fn rejects_sum_axes() {
// The summable declaration itself has to survive the aggregate
// cross-checks (integer type, required) so that the indexOnly-specific
// rejection is the one that fires.
fn accepts_summable_index() {
// The sum axes are admitted on indexOnly indexes: the terminal entry
// becomes an `ItemWithSumItem(commitment, amount)`. The summable
// declaration goes through the same doctype-level aggregate
// cross-checks as stored types (integer type, required membership),
// and the summed property must still satisfy the indexOnly
// every-property-indexed rule — here it joins byLiker's prefix.
let mut schema = likes_schema_with_index_key(2, "summable", platform_value!("likeWeight"));
schema
.get_mut("indices")
.expect("indices accessible")
.expect("indices present")
.as_array_mut()
.expect("indices is an array")
.get_mut(2)
.expect("index exists")
.set_value(
"properties",
platform_value!([{ "$ownerId": "asc" }, { "likeWeight": "asc" }]),
)
.expect("index properties apply");
schema
.get_mut("properties")
.expect("properties accessible")
Expand All @@ -306,9 +322,25 @@ fn rejects_sum_axes() {
platform_value!(["hashtag", "postId", "likeWeight"]),
)
.expect("required applies");
let document_type =
parse_with(schema, PlatformVersion::latest(), false).expect("summable index admitted");
let summable_index = document_type
.indices
.values()
.find(|index| index.summable.is_some())
.expect("an index carries the summable declaration");
assert_eq!(summable_index.summable.as_deref(), Some("likeWeight"));
}

#[test]
fn rejects_summable_naming_non_integer_property() {
// The doctype-level aggregate cross-checks (shared with stored types)
// still apply to indexOnly indexes: a summable naming a string
// property fails the integer-type rule.
let schema = likes_schema_with_index_key(2, "summable", platform_value!("hashtag"));
expect_structure_error(
parse_with(schema, PlatformVersion::latest(), false),
"sum axes",
"integer type",
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,4 +1155,224 @@ mod index_only_executed_proof_tests {
"expected the entry-still-present refusal, got: {error}"
);
}

/// A signed `tip` create for the given amount — the summable-type
/// counterpart of `signed_mark_create`. The `tip` doctype's proof index
/// (`byPost`) is summable, so its entries are
/// `ItemWithSumItem(commitment, amount)` and the executed-proof
/// verifier must check the proved sum contribution alongside the
/// commitment.
async fn signed_tip_create(
contract: &DataContract,
owner: Identifier,
post_id: Identifier,
amount: u64,
nonce: u64,
key: &dpp::identity::IdentityPublicKey,
signer: &SimpleSigner,
rng: &mut StdRng,
platform_version: &PlatformVersion,
) -> (StateTransition, Document) {
use dpp::document::DocumentV0Setters;
let tip_type = contract
.document_type_for_name("tip")
.expect("tip doctype exists");
let entropy = Bytes32::random_with_rng(rng);
let mut tip = tip_type
.random_document_with_identifier_and_entropy(
rng,
owner,
entropy,
DocumentFieldFillType::FillIfNotRequired,
DocumentFieldFillSize::AnyDocumentFillSize,
platform_version,
)
.expect("expected a random tip");
tip.set(
"postId",
dpp::platform_value::Value::Identifier(post_id.to_buffer()),
);
tip.set("amount", dpp::platform_value::Value::U64(amount));
let create = BatchTransition::new_document_creation_transition_from_document(
tip.clone(),
tip_type,
entropy.0,
key,
nonce,
0,
None,
signer,
platform_version,
None,
)
.await
.expect("expected the create transition");
(create, tip)
}

/// The summable lifecycle through executed proofs: a `tip` create
/// proves and verifies against its `ItemWithSumItem` entry, a forged
/// transition claiming a different amount at the same entry position
/// is refused on the sum contribution, and the executed delete proves
/// the entry absent.
#[tokio::test]
async fn test_executed_summable_tip_proofs_and_forged_amount_refused() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let platform_state = platform.state.load();
let mut rng = StdRng::seed_from_u64(4645);

let (alice, alice_signer, alice_key) =
setup_identity(&mut platform, 958, dash_to_credits!(1.0));
let contract = register_likes(&platform, alice.id(), platform_version);
let contract_arc = Arc::new(contract.clone());

let post = create_post(
&platform,
&platform_state,
&contract,
alice.id(),
&alice_key,
2,
&alice_signer,
&mut rng,
platform_version,
)
.await;

let (create, tip) = signed_tip_create(
&contract,
alice.id(),
post.id(),
150,
3,
&alice_key,
&alice_signer,
&mut rng,
platform_version,
)
.await;
let result = process_and_commit(&platform, &platform_state, &create, platform_version);
assert_eq!(
result.valid_count(),
1,
"the tip create must execute: {:?}",
result.execution_results()
);

// ── prove + verify the executed create ─────────────────────────
let proof = platform
.drive
.prove_state_transition(&create, None, platform_version)
.expect("expected to prove the executed create")
.into_data()
.expect("expected proof bytes");
let lookup = |_id: &dpp::identifier::Identifier| Ok(Some(Arc::clone(&contract_arc)));
let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof(
&create,
&BlockInfo::default(),
proof.as_slice(),
&lookup,
platform_version,
)
.expect("expected the executed summable create proof to verify");
assert_ne!(root_hash, [0u8; 32]);
let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else {
panic!("expected verified documents");
};
let (_, verified_tip) = documents.into_iter().next().expect("one document");
let verified_tip = verified_tip.expect("the created tip is present");
assert_eq!(
verified_tip
.properties()
.get("amount")
.expect("amount present")
.to_integer::<u64>()
.expect("integer"),
150
);

// ── forged amount at the same entry position is refused ────────
// Signed but never processed: the same (post, owner) addresses the
// same `byPost` entry — the amount is not in that index's path —
// but the stored sum contribution is the real row's 150.
let (forged_create, _forged_tip) = signed_tip_create(
&contract,
alice.id(),
post.id(),
999,
4,
&alice_key,
&alice_signer,
&mut rng,
platform_version,
)
.await;
let proof = platform
.drive
.prove_state_transition(&forged_create, None, platform_version)
.expect("expected to prove the entry position")
.into_data()
.expect("expected proof bytes");
let error = Drive::verify_state_transition_was_executed_with_proof(
&forged_create,
&BlockInfo::default(),
proof.as_slice(),
&lookup,
platform_version,
)
.expect_err("a forged amount must not verify as executed");
assert!(
error.to_string().contains("sum contribution"),
"expected the sum-contribution refusal, got: {error}"
);

// ── prove + verify the executed delete ─────────────────────────
let tip_type = contract
.document_type_for_name("tip")
.expect("tip doctype exists");
let delete = BatchTransition::new_document_deletion_transition_from_document(
tip,
tip_type,
&alice_key,
4,
0,
None,
&alice_signer,
platform_version,
None,
)
.await
.expect("expected the delete transition");
let result = process_and_commit(&platform, &platform_state, &delete, platform_version);
assert_eq!(
result.valid_count(),
1,
"the tip delete must execute: {:?}",
result.execution_results()
);

let proof = platform
.drive
.prove_state_transition(&delete, None, platform_version)
.expect("expected to prove the executed delete")
.into_data()
.expect("expected proof bytes");
let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof(
&delete,
&BlockInfo::default(),
proof.as_slice(),
&lookup,
platform_version,
)
.expect("expected the executed summable delete proof to verify");
assert_ne!(root_hash, [0u8; 32]);
let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else {
panic!("expected verified documents");
};
let (_, absent) = documents.into_iter().next().expect("one entry");
assert!(absent.is_none(), "the deleted tip must be proven absent");
}
}
Loading
Loading