From 9de6dacfc02e2541981c9c0effe4a71114d386e5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 8 Oct 2026 16:17:29 +0200 Subject: [PATCH 01/62] feat(platform)!: dpns contract v3 checks name registration with schema keywords From protocol version 14 the DPNS `domain` create is checked by the contract's own keywords instead of most of its data trigger: - `normalizedLabel` and `normalizedParentDomainName` are generated from `label` and `parentDomainName` (`generatedFrom` homographSafeASCII); - `preorderSalt` reveals the writer's own preorder, made in an earlier block, and deletes it (`refersTo` with a sha256d `findBy`, `where` `$ownerId`, `minimumAgeBlocks: 1`, `consume`); the preorder's storage is refunded to the writer; - the `recordsIdentityIsOwner` rule holds a new name's `records.identity` to its owner until the name first changes hands; - `domain` sets `canBeDeleted: false`; `preorder` requires `$createdAtBlockHeight`. Positions, transient list and encoding are v2's. `create_domain_data_trigger` 2 keeps only the parent domain checks, and data trigger bindings 2 drop the `domain` Replace and Delete rejects. `transition_to_version_14` re-stores DPNS. A preorder recording no `$createdAtBlockHeight` (made before the upgrade) counts as old enough for `minimumAgeBlocks`. The legacy JS SDK hashes the preorder as the platform does: normalized label, ".", parent as sent. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 2 +- .../ownership-and-trading.md | 2 +- .../contract-keywords/property-constraints.md | 1 + .../src/contract-keywords/refers-to-lookup.md | 4 +- book/src/contract-keywords/transient.md | 2 +- book/src/data-model/documents.md | 6 +- .../schema/v3/dpns-contract-documents.json | 220 ++++ packages/dpns-contract/src/lib.rs | 8 +- packages/dpns-contract/src/v3/mod.rs | 18 + .../Platform/methods/names/register.spec.ts | 2 +- .../Client/Platform/methods/names/register.ts | 6 +- ...nced_document_requirement_not_met_error.rs | 3 +- .../v0/mod.rs | 22 +- .../document_reference_validation/v0/mod.rs | 21 +- .../data_triggers/bindings/list/v2/mod.rs | 19 +- .../batch/data_triggers/triggers/dpns/mod.rs | 7 +- .../data_triggers/triggers/dpns/v2/mod.rs | 191 +++ .../batch/tests/document/creation.rs | 72 +- .../batch/tests/document/dpns.rs | 54 +- .../batch/tests/document/dpns_v3.rs | 1139 +++++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../tests/document/owner_balance_proof.rs | 7 +- .../state_transition/state_transitions/mod.rs | 59 +- .../strategy_tests/test_cases/voting_tests.rs | 498 ++++--- .../drive_abci_validation_versions/v10.rs | 10 +- .../system_data_contract_versions/v3.rs | 10 +- .../rs-platform-version/src/version/v14.rs | 44 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 3 +- 28 files changed, 2071 insertions(+), 360 deletions(-) create mode 100644 packages/dpns-contract/schema/v3/dpns-contract-documents.json create mode 100644 packages/dpns-contract/src/v3/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 766537a582c..1f70f53501e 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -42,7 +42,7 @@ A client creates `{ "label": "Bob" }` and the stored document holds `{ "label": The generated property needs no `pattern` of its own. Every value it can hold is what the function generates from params that passed their own patterns: here `label` admits ASCII letters, digits and `-`, so `normalizedLabel` can only ever hold `a` to `z` without `i`, `l` and `o`, digits and `-`. It keeps `maxLength` because it is indexed, and an indexed string declares one of at most 63. -This is the rule the DPNS `domain` type's data trigger checks today for `normalizedLabel` and `normalizedParentDomainName`, written into the schema. +The DPNS `domain` type declares this rule for `normalizedLabel` and `normalizedParentDomainName` from protocol version 14 (DPNS contract v3). Before it, the type's create data trigger checked the same rule. ## Functions diff --git a/book/src/contract-keywords/ownership-and-trading.md b/book/src/contract-keywords/ownership-and-trading.md index e7425908526..f34f95aad53 100644 --- a/book/src/contract-keywords/ownership-and-trading.md +++ b/book/src/contract-keywords/ownership-and-trading.md @@ -64,7 +64,7 @@ Whether an owner may give a document to another identity. - The owner sends a transfer transition naming the document, the recipient and a `$revision` one higher than the stored one (`InvalidDocumentRevisionError`, 40106). Only the owner may transfer (`DocumentOwnerIdMismatchError`, 40102). The recipient does not have to agree. - The document gets the recipient as its `$ownerId` and a new `$revision`. A price it was listed at is removed, so a transferred document is no longer for sale. When the type lists `$transferredAt` in `required`, it is set to the block's time, and likewise `$transferredAtBlockHeight` and `$transferredAtCoreBlockHeight` to the block heights. -- A transfer carries no property values, and `$creatorId` keeps naming the identity that created the document. The one change the platform makes itself: from protocol version 13, a DPNS `domain` that is transferred or sold has its `records.identity` pointed at the new owner. +- A transfer carries no property values, and `$creatorId` keeps naming the identity that created the document. The one change the platform makes itself: from protocol version 13, a DPNS `domain` that is transferred or sold has its `records.identity` pointed at the new owner. From protocol version 14 the type's `recordsIdentityIsOwner` rule holds a new name's `records.identity` to its owner until it first changes hands (see [propertyConstraints](property-constraints.md)), so a name can not be transferred or sold in the block that created it. - The document is checked as it will be stored, with its new owner: against the type's unique indexes (`DuplicateUniqueIndexError`, 40105), against a `distinctFrom: "$ownerId"` property (`DocumentPropertyNotDistinctError`, 10419), and against `propertyConstraints` rules that read `$ownerId` (`DocumentPropertyConstraintViolatedError`, 10422). - On a moderated contract, a transfer to a banned or suspended identity is refused (`ContractModerationCounterpartyBarredError`, 41114). - A transfer of a document past its `ttl` expiry is refused (`DocumentExpiredError`, 40140). diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index c986d0ffe51..4afc8d51892 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -169,6 +169,7 @@ A rule can read when the document was created, last updated and last transferred - `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation. - `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after the listing ends. - `{ "lessThanOrEqual": ["$transferredAt", "endsAt"] }` refuses a transfer or a purchase after it ends. +- `{ "ifThen": [{ "equal": ["$transferredAt", "$createdAt"] }, { "equal": ["records.identity", "$ownerId"] }] }` holds `records.identity` to the owner until the document first changes hands. A create records its block's time as the transfer too, so the condition holds on the create, and a transfer or a purchase in a later block records a later time, so it no longer reads the property. One in the block that created the document has that block's time and is judged as the create was. The DPNS `domain` type's `recordsIdentityIsOwner` (protocol version 14) is this rule. A rule may read one only when the document type records it by listing it in `required`, so every stored document holds it. None takes an `ifAbsent` default, `present` and `absent` refuse them, and an [index-only](index-only.md) type reads none. Each write is judged with the values the stored document ends up with: a create with its block's time and heights for all three events; a replace with the stored creation and transfer values and its block's as the update; a price update with its block's as the update; a transfer or a purchase with its block's as the transfer. diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index efbf1cad3a4..c469e3565f3 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -119,7 +119,7 @@ The value of a reference found by `findBy` is a key part, not a document id. So ## Commit and reveal -One `findBy` entry may hold a function: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds the SHA-256 of the SHA-256 of the params' bytes, joined in order. The platform fills that property of the key with the hash to find the document, like any other entry. The document it finds is a commitment made earlier, one that stored that hash, so the document being created may exist only while a commitment to values it carries exists. This is how a name registration that preorders a salted hash works, written as a declaration on the salt: +One `findBy` entry may hold a function: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds the SHA-256 of the SHA-256 of the params' bytes, joined in order. The platform fills that property of the key with the hash to find the document, like any other entry. The document it finds is a commitment made earlier, one that stored that hash, so the document being created may exist only while a commitment to values it carries exists. This is how the DPNS `domain` type registers a name from protocol version 14 (DPNS contract v3), a declaration on the salt: ```json "preorderSalt": { @@ -158,7 +158,7 @@ WHERE saltedDomainHash = -- findBy `minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds, and need a function in it. -- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. +- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. A commitment recording no creation height counts as old enough: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, all written before the upgrade's block. DPNS preorders made before protocol version 14 are revealed this way. - **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference with the `where` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. diff --git a/book/src/contract-keywords/transient.md b/book/src/contract-keywords/transient.md index 284f0f11e30..a8a883cf32a 100644 --- a/book/src/contract-keywords/transient.md +++ b/book/src/contract-keywords/transient.md @@ -36,7 +36,7 @@ The DPNS `domain` type, trimmed to two properties: } ``` -Registering a name takes two steps. A `preorder` document first commits to a hash of the salt and the name, and the `domain` document then reveals both. Every domain create must carry its 32-byte `preorderSalt`, and DPNS checks it against the preorder. Once the domain is created the salt has done its job, and the stored domain does not hold it. +Registering a name takes two steps. A `preorder` document first commits to a hash of the salt and the name, and the `domain` document then reveals both. Every domain create must carry its 32-byte `preorderSalt`, and DPNS checks it against the preorder: from protocol version 14 through a `refersTo` on the salt that finds the writer's preorder and deletes it ([Commit and reveal](refers-to-lookup.md#commit-and-reveal)), before it in the type's create data trigger. Once the domain is created the salt has done its job, and the stored domain does not hold it. ## How it works diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index db82dc549d3..f9be6870a25 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -510,7 +510,7 @@ A document type whose documents can be transferred or traded declares `creatorRe ### Commit and reveal (a `findBy` function) -One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind, done today by its `domain` create trigger rather than by a declaration: a `preorder` holds `saltedDomainHash` under a unique index, and the trigger hashes what the domain reveals and looks for it. The same rule for a name under a parent, written as a declaration on the salt the domain reveals, reads: +One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind: a `preorder` holds `saltedDomainHash` under a unique index. Before protocol version 14 the `domain` create trigger hashed what the domain reveals and looked for it; from 14, DPNS contract v3 declares it on the salt the domain reveals: ```json "preorder": { @@ -552,7 +552,7 @@ One `findBy` entry may hold a function instead of a source: `" Result, Error> { match platform_version.system_data_contracts.dpns { - 1 | 2 => Ok(None), + 1..=3 => Ok(None), version => Err(Error::UnknownVersionMismatch { method: "dpns_contract::load_definitions".to_string(), - known_versions: vec![1, 2], + known_versions: vec![1, 2, 3], received: version, }), } @@ -40,9 +41,10 @@ pub fn load_documents_schemas(platform_version: &PlatformVersion) -> Result v1::load_documents_schemas(), 2 => v2::load_documents_schemas(), + 3 => v3::load_documents_schemas(), version => Err(Error::UnknownVersionMismatch { method: "dpns_contract::load_documents_schemas".to_string(), - known_versions: vec![1, 2], + known_versions: vec![1, 2, 3], received: version, }), } diff --git a/packages/dpns-contract/src/v3/mod.rs b/packages/dpns-contract/src/v3/mod.rs new file mode 100644 index 00000000000..80c1319b4d8 --- /dev/null +++ b/packages/dpns-contract/src/v3/mod.rs @@ -0,0 +1,18 @@ +use crate::Error; +use serde_json::Value; + +/// The document type names and property names are unchanged from v1 (see +/// [`crate::v1::document_types`]), and so are every property's position and the +/// transient list, so documents written under v1 or v2 decode unchanged. v3 replaces +/// most of the `domain` create data trigger with schema keywords: `normalizedLabel` +/// and `normalizedParentDomainName` are generated from their counterparts +/// (`generatedFrom`), `preorderSalt` reveals the writer's own preorder from an +/// earlier block and deletes it (`refersTo` with `findBy`, `where`, +/// `minimumAgeBlocks` and `consume`, which needs the preorder to record +/// `$createdAtBlockHeight`), and the `recordsIdentityIsOwner` rule holds a new +/// domain's identity record to its owner. Domains can no longer be deleted +/// (`canBeDeleted: false`). The parent domain checks stay in the create trigger. +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!("../../schema/v3/dpns-contract-documents.json")) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 17404c0fb07..3175535a856 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -59,7 +59,7 @@ describe('Platform', () => { expect(platformMock.documents.create.getCall(0).args[0]).to.deep.equal('dpns.preorder'); expect(platformMock.documents.create.getCall(0).args[1]).to.deep.equal(identityMock); expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.deep.equal( - 'df46c47179745ea18c0fdc95910372dca8810127acc9afe3c9b326b07555e6b4', + 'dad5808c709bec62b65e607b38846d4fe4080b251cb917a2221c81fb7c3b5f5e', ); expect(platformMock.documents.create.getCall(1).args).to.have.deep.members([ diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 07f6576e782..7196ad42ab4 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -48,9 +48,9 @@ export async function register( const isSecondLevelDomain = normalizedParentDomainName.length > 0; - const fullDomainName = isSecondLevelDomain - ? `${normalizedLabel}.${normalizedParentDomainName}` - : normalizedLabel; + // The preorder commits to the normalized label, a dot and the parent domain + // name as sent: the hash the platform checks the domain's preorderSalt against + const fullDomainName = `${normalizedLabel}.${parentDomainName}`; const saltedDomainHash = hash( Buffer.concat([ diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs index 89b2ec5b3f8..a5907fce99c 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs @@ -11,7 +11,8 @@ use thiserror::Error; /// The document a `refersTo` lookup with a computed key found, the commitment a create /// reveals, exists but does not meet what the reference requires of it: `minimumAgeBlocks`, /// its recorded creation block height at least that many blocks below the height of the -/// create (a document recording none never meets it). +/// create (a document recording none, written before a protocol upgrade re-stored its system +/// contract, counts as old enough). #[derive( Error, Debug, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 04c67e62192..00ec379b784 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -702,8 +702,9 @@ impl Platform { /// When transitioning to version 14 we re-store the DashPay contract whose /// v2 schema adds the optional public payment address fields to the /// `profile` document type (DIP-33), the withdrawals contract whose v2 - /// schema admits the terminal FAILED value of the `status` property, and - /// register the app-connect contract that carries the wallet-to-app login + /// schema admits the terminal FAILED value of the `status` property, the + /// DPNS contract whose v3 schema checks a domain create with keywords + /// instead of most of its data trigger, and register the app-connect contract that carries the wallet-to-app login /// handshake and the moderation charters contract that elected moderation /// teams apply through. fn transition_to_version_14( @@ -739,6 +740,23 @@ impl Platform { platform_version, )?; + // DPNS contract v3: a domain create generates its normalized names, reveals and + // deletes the writer's own preorder from an earlier block, and holds its identity + // record to its owner, all by schema keywords; the create data trigger keeps only + // the parent domain checks. Preorders made before this block record no + // `$createdAtBlockHeight` and stay revealable: a document recording none counts as + // old enough for `minimumAgeBlocks`. + let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; + + self.drive.apply_contract( + &dpns_contract, + *block_info, + true, + None, + Some(transaction), + platform_version, + )?; + // App-connect contract: the wallet's encrypted login response gets one system // contract id on every network from this version. Fresh // chains register it at genesis (`create_genesis_state` v2). diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 46665048458..5c1e2afd3a8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -1426,15 +1426,22 @@ fn validate_reference_target_v0( // The commitment a computed key found: it must be old enough, and the create // deletes it when the reference consumes it. Its age is judged in blocks, from its // `$createdAtBlockHeight` (registration demands the type record one) to the - // block of the create, so 1 means an earlier block; a document recording none - // never meets it. Only a lookup the protocol version 14 parser produced has - // either + // block of the create, so 1 means an earlier block. A document recording none + // counts as old enough: a contract update cannot make a system field required, + // so it was written before a protocol upgrade re-stored a system contract whose + // type now records one (DPNS v3's preorder at protocol version 14), and the + // upgrade runs before the first transition of its block, so the document is from + // an earlier block. Only a lookup the protocol version 14 parser produced has + // either, so no earlier protocol version reaches this if let (Some(lookup), Some(found)) = (lookup, referenced_document) { if let Some(blocks) = lookup.minimum_age_blocks { - let old_enough = found - .created_at_block_height() - .and_then(|created_at| block_info.height.checked_sub(created_at)) - .is_some_and(|age| age >= u64::from(blocks)); + let old_enough = match found.created_at_block_height() { + Some(created_at) => block_info + .height + .checked_sub(created_at) + .is_some_and(|age| age >= u64::from(blocks)), + None => true, + }; if !old_enough { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentRequirementNotMetError::new( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs index 4b7963db392..a115f789e6d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs @@ -17,7 +17,10 @@ use drive::state_transition_action::batch::batched_transition::document_transiti /// v2 (PROTOCOL_VERSION_14): DashPay `profile` documents gain Create and /// Replace triggers enforcing the DIP-33 payment-address type byte /// (`0x00` P2PKH / `0x01` P2SH) that the schema vocabulary cannot express. -/// Everything else is unchanged from v1. +/// DPNS `domain` Replace and Delete lose their reject bindings: DPNS v3 +/// refuses both with `documentsMutable` and `canBeDeleted` false. Its Create +/// binding stays for the parent domain checks (`create_domain_data_trigger` +/// v2). Everything else is unchanged from v1. /// /// # Returns /// @@ -35,20 +38,6 @@ pub(super) fn data_trigger_bindings_list_v2() -> Result create_domain_data_trigger_v1(document_transition, context, platform_version), + // PROTOCOL_VERSION_14+: only the parent domain checks; DPNS v3's schema + // keywords check the normalized names, the preorder and the records. + 2 => create_domain_data_trigger_v2(document_transition, context, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "create_domain_data_trigger".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs new file mode 100644 index 00000000000..48394f21de6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs @@ -0,0 +1,191 @@ +use std::collections::BTreeMap; + +use dpp::consensus::state::data_trigger::data_trigger_condition_error::DataTriggerConditionError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::DocumentV0Getters; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::btreemap_extensions::{BTreeValueMapHelper, BTreeValueMapPathHelper}; +use dpp::platform_value::Value; +use dpp::system_data_contracts::dpns_contract; +use dpp::system_data_contracts::dpns_contract::v1::document_types::domain::properties::{ + ALLOW_SUBDOMAINS, NORMALIZED_LABEL, NORMALIZED_PARENT_DOMAIN_NAME, +}; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; +use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; +use crate::execution::validation::state_transition::batch::data_triggers::{ + DataTriggerExecutionContext, DataTriggerExecutionResult, +}; + +/// The DPNS `domain` create trigger from protocol version 14, when DPNS v3's +/// schema keywords check the rest of a create: the normalized names are +/// generated from their counterparts (`generatedFrom`), the preorder is the +/// writer's own from an earlier block and the create deletes it (`refersTo` on +/// `preorderSalt`), and the identity record is the owner (`propertyConstraints` +/// rule `recordsIdentityIsOwner`). +/// +/// What stays here is the domain's place in the name hierarchy: +/// +/// * a top-level domain (an empty `normalizedParentDomainName`) only by the +/// DPNS contract owner; +/// * otherwise the parent domain must exist (a billed Drive query, as in v1), +/// the new domain may not allow subdomains, and a parent that does not allow +/// subdomains takes them only from its own owner. +/// +/// # Returns +/// +/// A `DataTriggerExecutionResult` holding a `DataTriggerConditionError` for the +/// first failed check, or none. A dry run (`check_tx`) runs the parent query to +/// bill it and judges nothing. +#[inline(always)] +pub(super) fn create_domain_data_trigger_v2( + document_transition: &DocumentTransitionAction, + context: &mut DataTriggerExecutionContext<'_>, + platform_version: &PlatformVersion, +) -> Result { + let data_contract_fetch_info = document_transition.base().data_contract_fetch_info(); + let data_contract = &data_contract_fetch_info.contract; + let is_dry_run = context.state_transition_execution_context.in_dry_run(); + let DocumentTransitionAction::CreateAction(document_create_transition) = document_transition + else { + return Err(Error::Execution(ExecutionError::DataTriggerExecutionError( + format!( + "the Document Transition {} isn't 'CREATE", + document_transition.base().id() + ), + ))); + }; + + // The action transformer has generated `normalizedParentDomainName` when the + // transition left it out, so it is always here + let data = document_create_transition.data(); + let normalized_parent_domain_name = data + .get_string(NORMALIZED_PARENT_DOMAIN_NAME) + .map_err(ProtocolError::ValueError)?; + let rule_allow_subdomains = data + .get_bool_at_path(ALLOW_SUBDOMAINS) + .map_err(ProtocolError::ValueError)?; + + let mut result = DataTriggerExecutionResult::default(); + + if normalized_parent_domain_name.is_empty() { + if !is_dry_run && context.owner_id != &dpns_contract::OWNER_ID { + result.add_error(DataTriggerConditionError::new( + data_contract.id(), + document_transition.base().id(), + "Can't create top level domain for this identity".to_string(), + )); + } + return Ok(result); + } + + // The parent is the domain whose label is the first segment of the parent + // name, under the rest of it + let (parent_domain_label, grand_parent_domain_name) = normalized_parent_domain_name + .split_once('.') + .unwrap_or((normalized_parent_domain_name.as_str(), "")); + + let document_type = data_contract.document_type_for_name( + document_create_transition + .base() + .document_type_name() + .as_str(), + )?; + + let drive_query = DriveDocumentQuery { + contract: data_contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: BTreeMap::from([ + ( + NORMALIZED_PARENT_DOMAIN_NAME.to_string(), + WhereClause { + field: NORMALIZED_PARENT_DOMAIN_NAME.to_string(), + operator: WhereOperator::Equal, + value: Value::Text(grand_parent_domain_name.to_string()), + }, + ), + ( + NORMALIZED_LABEL.to_string(), + WhereClause { + field: NORMALIZED_LABEL.to_string(), + operator: WhereOperator::Equal, + value: Value::Text(parent_domain_label.to_string()), + }, + ), + ]), + }, + offset: None, + limit: None, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + + let parent_domain_outcome = context.platform.drive.query_documents( + drive_query, + Some(&context.block_info.epoch), + is_dry_run, + context.transaction, + Some(platform_version.protocol_version), + )?; + context.state_transition_execution_context.add_operation( + ValidationOperation::PrecalculatedOperation(FeeResult { + processing_fee: parent_domain_outcome.cost(), + ..Default::default() + }), + ); + + if is_dry_run { + return Ok(result); + } + + let documents = parent_domain_outcome.documents_owned(); + let Some(parent_domain) = documents.first() else { + result.add_error(DataTriggerConditionError::new( + data_contract.id(), + document_transition.base().id(), + "Parent domain is not present".to_string(), + )); + return Ok(result); + }; + + if rule_allow_subdomains { + result.add_error(DataTriggerConditionError::new( + data_contract.id(), + document_transition.base().id(), + "Allowing subdomains registration is forbidden for this domain".to_string(), + )); + return Ok(result); + } + + let parent_allows_subdomains = parent_domain + .properties() + .get_bool_at_path(ALLOW_SUBDOMAINS) + .map_err(ProtocolError::ValueError)?; + if !parent_allows_subdomains && context.owner_id != &parent_domain.owner_id() { + result.add_error(DataTriggerConditionError::new( + data_contract.id(), + document_transition.base().id(), + "The subdomain can be created only by the parent domain owner".to_string(), + )); + } + + Ok(result) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index bc3fba52585..a35d91f709c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -751,8 +751,12 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is disabled with data trigger - assert!(domain.documents_can_be_deleted()); + // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` + // from DPNS v3 (protocol version 14) + assert_eq!( + domain.documents_can_be_deleted(), + platform_version.system_data_contracts.dpns < 3 + ); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -967,7 +971,9 @@ mod creation_tests { documents_batch_create_serialized_transition_2.clone(), ], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -1226,8 +1232,12 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is disabled with data trigger - assert!(domain.documents_can_be_deleted()); + // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` + // from DPNS v3 (protocol version 14) + assert_eq!( + domain.documents_can_be_deleted(), + platform_version.system_data_contracts.dpns < 3 + ); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -1367,7 +1377,9 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -1656,7 +1668,9 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -2079,8 +2093,12 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is disabled with data trigger - assert!(domain.documents_can_be_deleted()); + // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` + // from DPNS v3 (protocol version 14) + assert_eq!( + domain.documents_can_be_deleted(), + platform_version.system_data_contracts.dpns < 3 + ); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -2219,7 +2237,9 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -2367,8 +2387,12 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is disabled with data trigger - assert!(domain.documents_can_be_deleted()); + // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` + // from DPNS v3 (protocol version 14) + assert_eq!( + domain.documents_can_be_deleted(), + platform_version.system_data_contracts.dpns < 3 + ); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -2776,7 +2800,9 @@ mod creation_tests { documents_batch_create_serialized_transition_2.clone(), ], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -2800,7 +2826,9 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_3.clone()], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -2827,7 +2855,9 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_4], &platform_state, - &BlockInfo::default(), + // The block after the preorders: from protocol version 14 a domain + // reveals a preorder made in an earlier block + &BlockInfo::default_with_height(1), &transaction, platform_version, false, @@ -3456,7 +3486,7 @@ mod creation_tests { let extra_time_used = 3000; // add_contender_to_dpns_name_contest uses this extra time let expected_error_message = format!( - "Document Contest for vote_poll ContestedDocumentResourceVotePoll {{ contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }} is not joinable V0(ContestedDocumentVotePollStoredInfoV0 {{ finalized_events: [], vote_poll_status: Started(BlockInfo {{ time_ms: {}, height: 0, core_height: 0, epoch: 0 }}), locked_count: 0 }}), it started {} and it is now {}, and you can only join for {}", + "Document Contest for vote_poll ContestedDocumentResourceVotePoll {{ contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }} is not joinable V0(ContestedDocumentVotePollStoredInfoV0 {{ finalized_events: [], vote_poll_status: Started(BlockInfo {{ time_ms: {}, height: 1, core_height: 0, epoch: 0 }}), locked_count: 0 }}), it started {} and it is now {}, and you can only join for {}", time_started + extra_time_used, time_started + extra_time_used, time_now + extra_time_used, @@ -3626,9 +3656,15 @@ mod creation_tests { let SuccessfulExecution { fee_result, .. } = result else { panic!("expected the contender to join, got {result:?}"); }; + // From protocol version 14 the join consumes the contender's preorder, whose storage + // is refunded to it + let preorder_refund = fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(contender.id()) + .unwrap_or_default(); assert_eq!( balance_before_create - balance_of(platform, &contender, platform_version), - fund + fee_result.total_base_fee(), + fund + fee_result.total_base_fee() - preorder_refund, "the contender pays the fund to join and the fees of its document" ); } @@ -4180,7 +4216,7 @@ mod creation_tests { // We expect this to fail let expected_error_message = format!( - "Document Contest for vote_poll ContestedDocumentResourceVotePoll {{ contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }} is currently already locked V0(ContestedDocumentVotePollStoredInfoV0 {{ finalized_events: [ContestedDocumentVotePollStoredInfoVoteEventV0 {{ resource_vote_choices: [FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW), voters: [2oGomAQc47V9h3mkpyHUPbF74gT2AmoYKg1oSb94Rbwm:1, 4iroeiNBeBYZetCt21kW7FGyczE8WqoqzZ48YAHwyV7R:1, Cdf8V4KGHHd395x5xPJPPrzTKwmp5MqbuszSE2iMzzeP:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(FiLk5pGtspYtF65PKsQq3YFr1DEiXPHTZeKjusT6DuqN), voters: [] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(Fv8S6kTbNrRqKC7PR7XcRUoPR59bxNhhggg5mRaNN6ow), voters: [4MK8GWEWX1PturUqjZJefdE4WGrUqz1UQZnbK17ENkeA:1, 5gRudU7b4n8LYkNvhZomv6FtMrP7gvaTvRrHKfaTS22K:1, AfzQBrdwzDuTVdXrMWqQyVvXRWqPMDVjA76hViuGLh6W:1, E75wdFZB22P1uW1wJBJGPgXZuZKLotK7YmbH5wUk5msH:1, G3ZfS2v39x6FuLGnnJ1RNQyy4zn4Wb64KiGAjqj39wUu:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: Abstain, voters: [5Ur8tDxJnatfUd9gcVFDde7ptHydujZzJLNTxa6aMYYy:1, 93Gsg14oT9K4FLYmC7N26uS4g5b7JcM1GwGEDeJCCBPJ:1, 96eX4PTjbXRuGHuMzwXdptWFtHcboXbtevk51Jd73pP7:1, AE9xm2mbemDeMxPUzyt35Agq1axRxggVfV4DRLAZp7Qt:1, FbLyu5d7JxEsvSsujj7Wopg57Wrvz9HH3UULCusKpBnF:1, GsubMWb3LH1skUJrcxTmZ7wus1habJcbpb8su8yBVqFY:1, H9UrL7aWaxDmXhqeGMJy7LrGdT2wWb45mc7kQYsoqwuf:1, Hv88mzPZVKq2fnjoUqK56vjzkcmqRHpWE1ME4z1MXDrw:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: Lock, voters: [F1oA8iAoyJ8dgCAi2GSPqcNhp9xEuAqhP47yXBDw5QR:1, 2YSjsJUp74MJpm12rdn8wyPR5MY3c322pV8E8siw989u:1, 3fQrmN4PWhthUFnCFTaJqbT2PPGf7MytAyik4eY1DP8V:1, 7r7gnAiZunVLjtSd5ky4yvPpnWTFYbJuQAapg8kDCeNK:1, 86TUE89xNkBDcmshXRD198xjAvMmKecvHbwo6i83AmqA:1, 97iYr4cirPdG176kqa5nvJWT9tsnqxHmENfRnZUgM6SC:1, 99nKfYZL4spsTe9p9pPNhc1JWv9yq4CbPPMPm87a5sgn:1, BYAqFxCVwMKrw5YAQMCFQGiAF2v3YhKRm2EdGfgkYN9G:1, CGKeK3AfdZUxXF3qH9zxp5MR7Z4WvDVqMrU5wjMKqT5C:1, HRPPEX4mdoZAMkg6NLJUgDzN4pSTpiDXEAGcR5JBdiXX:1] }}], start_block: BlockInfo {{ time_ms: 3000, height: 0, core_height: 0, epoch: 0 }}, finalization_block: BlockInfo {{ time_ms: {}, height: 900, core_height: 42, epoch: 0 }}, winner: Locked }}], vote_poll_status: Locked, locked_count: 1 }}), unlocking is possible by paying 400000000000 credits", + "Document Contest for vote_poll ContestedDocumentResourceVotePoll {{ contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }} is currently already locked V0(ContestedDocumentVotePollStoredInfoV0 {{ finalized_events: [ContestedDocumentVotePollStoredInfoVoteEventV0 {{ resource_vote_choices: [FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW), voters: [2oGomAQc47V9h3mkpyHUPbF74gT2AmoYKg1oSb94Rbwm:1, 4iroeiNBeBYZetCt21kW7FGyczE8WqoqzZ48YAHwyV7R:1, Cdf8V4KGHHd395x5xPJPPrzTKwmp5MqbuszSE2iMzzeP:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(FiLk5pGtspYtF65PKsQq3YFr1DEiXPHTZeKjusT6DuqN), voters: [] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: TowardsIdentity(Fv8S6kTbNrRqKC7PR7XcRUoPR59bxNhhggg5mRaNN6ow), voters: [4MK8GWEWX1PturUqjZJefdE4WGrUqz1UQZnbK17ENkeA:1, 5gRudU7b4n8LYkNvhZomv6FtMrP7gvaTvRrHKfaTS22K:1, AfzQBrdwzDuTVdXrMWqQyVvXRWqPMDVjA76hViuGLh6W:1, E75wdFZB22P1uW1wJBJGPgXZuZKLotK7YmbH5wUk5msH:1, G3ZfS2v39x6FuLGnnJ1RNQyy4zn4Wb64KiGAjqj39wUu:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: Abstain, voters: [5Ur8tDxJnatfUd9gcVFDde7ptHydujZzJLNTxa6aMYYy:1, 93Gsg14oT9K4FLYmC7N26uS4g5b7JcM1GwGEDeJCCBPJ:1, 96eX4PTjbXRuGHuMzwXdptWFtHcboXbtevk51Jd73pP7:1, AE9xm2mbemDeMxPUzyt35Agq1axRxggVfV4DRLAZp7Qt:1, FbLyu5d7JxEsvSsujj7Wopg57Wrvz9HH3UULCusKpBnF:1, GsubMWb3LH1skUJrcxTmZ7wus1habJcbpb8su8yBVqFY:1, H9UrL7aWaxDmXhqeGMJy7LrGdT2wWb45mc7kQYsoqwuf:1, Hv88mzPZVKq2fnjoUqK56vjzkcmqRHpWE1ME4z1MXDrw:1] }}, FinalizedResourceVoteChoicesWithVoterInfo {{ resource_vote_choice: Lock, voters: [F1oA8iAoyJ8dgCAi2GSPqcNhp9xEuAqhP47yXBDw5QR:1, 2YSjsJUp74MJpm12rdn8wyPR5MY3c322pV8E8siw989u:1, 3fQrmN4PWhthUFnCFTaJqbT2PPGf7MytAyik4eY1DP8V:1, 7r7gnAiZunVLjtSd5ky4yvPpnWTFYbJuQAapg8kDCeNK:1, 86TUE89xNkBDcmshXRD198xjAvMmKecvHbwo6i83AmqA:1, 97iYr4cirPdG176kqa5nvJWT9tsnqxHmENfRnZUgM6SC:1, 99nKfYZL4spsTe9p9pPNhc1JWv9yq4CbPPMPm87a5sgn:1, BYAqFxCVwMKrw5YAQMCFQGiAF2v3YhKRm2EdGfgkYN9G:1, CGKeK3AfdZUxXF3qH9zxp5MR7Z4WvDVqMrU5wjMKqT5C:1, HRPPEX4mdoZAMkg6NLJUgDzN4pSTpiDXEAGcR5JBdiXX:1] }}], start_block: BlockInfo {{ time_ms: 3000, height: 1, core_height: 0, epoch: 0 }}, finalization_block: BlockInfo {{ time_ms: {}, height: 900, core_height: 42, epoch: 0 }}, winner: Locked }}], vote_poll_status: Locked, locked_count: 1 }}), unlocking is possible by paying 400000000000 credits", time_after_distribution_limit ); 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 219d00da376..4348c8449ea 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 @@ -1180,9 +1180,16 @@ mod dpns_username_transfer_tests { .serialize_to_bytes() .expect("expected domain batch serialized state transition"); - for serialized_transition in [ - documents_batch_create_serialized_preorder_transition, - documents_batch_create_serialized_transition, + // The preorder and the domain in consecutive blocks, since from protocol + // version 14 a domain reveals a preorder made in an earlier block, and both + // before the transfers and sales of the tests below, which run at +3000: a + // transfer at the domain's own creation time would be judged as its create + let last_block_time_ms = platform_state + .last_committed_block_time_ms() + .unwrap_or_default(); + for (block_offset, serialized_transition) in [ + (1, documents_batch_create_serialized_preorder_transition), + (2, documents_batch_create_serialized_transition), ] { let transaction = platform.drive.grove.start_transaction(); @@ -1191,12 +1198,11 @@ mod dpns_username_transfer_tests { .process_raw_state_transitions( &[serialized_transition], &platform_state, - &BlockInfo::default_with_time( - platform_state - .last_committed_block_time_ms() - .unwrap_or_default() - + 3000, - ), + &BlockInfo { + time_ms: last_block_time_ms + 1000 * block_offset, + height: block_offset, + ..Default::default() + }, &transaction, platform_version, false, @@ -2875,13 +2881,30 @@ mod dpns_username_transfer_tests { /// now change hands. /// /// `Replace` is stopped before data triggers run because the domain - /// document type is not mutable, but `Delete` is stopped only by the - /// reject data trigger (the domain document type sets `canBeDeleted: - /// true`), so this pins the single guard protecting names from deletion. + /// document type is not mutable. From protocol version 14 `Delete` is too: + /// DPNS v3 sets `canBeDeleted: false`, and the bindings no longer reject it. #[tokio::test] async fn test_dpns_username_replace_and_delete_still_rejected() { + run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( + PlatformVersion::latest().protocol_version, + false, + ) + .await; + } + + /// PROTOCOL_VERSION_13: DPNS v2's `domain` sets `canBeDeleted: true`, so + /// `Delete` is stopped only by the reject data trigger. + #[tokio::test] + async fn test_dpns_username_replace_and_delete_still_rejected_protocol_version_13() { + run_dpns_username_replace_and_delete_still_rejected_at_protocol_version(13, true).await; + } + + async fn run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( + protocol_version: ProtocolVersion, + delete_rejected_by_data_trigger: bool, + ) { let mut platform = TestPlatformBuilder::new() - .with_initial_protocol_version(PlatformVersion::latest().protocol_version) + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -2943,7 +2966,10 @@ mod dpns_username_transfer_tests { for (transition, expect_data_trigger_reject) in [ (documents_batch_replace_transition, false), - (documents_batch_delete_transition, true), + ( + documents_batch_delete_transition, + delete_rejected_by_data_trigger, + ), ] { let serialized_transition = transition .serialize_to_bytes() diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs new file mode 100644 index 00000000000..c50018f3b17 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -0,0 +1,1139 @@ +//! The DPNS contract v3 (protocol version 14) through the full ABCI pipeline. A +//! domain create is checked by schema keywords where the create data trigger +//! checked it before: `normalizedLabel` and `normalizedParentDomainName` are +//! generated from their counterparts, `preorderSalt` reveals the writer's own +//! preorder from an earlier block and deletes it, and the +//! `recordsIdentityIsOwner` rule holds a new name's identity record to its owner. +//! The trigger keeps only the parent domain checks. Protocol version 13 keeps +//! DPNS v2 and the whole trigger, pinned beside each case. + +use super::*; + +mod dpns_v3_tests { + use super::*; + use crate::execution::validation::state_transition::tests::{ + create_dpns_name_contest_give_key_info, perform_votes_multi, + }; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; + use dpp::consensus::basic::BasicError; + use dpp::consensus::codes::ErrorWithCode; + use dpp::consensus::state::data_trigger::DataTriggerError; + use dpp::data_contract::DataContract; + use dpp::data_contracts::SystemDataContract; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; + use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; + use dpp::prelude::{Identifier, IdentityNonce}; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; + use dpp::state_transition::StateTransition; + use dpp::system_data_contracts::dpns_contract; + use dpp::util::hash::hash_double; + use dpp::util::strings::convert_to_homograph_safe_chars; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; + use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; + use rand::prelude::StdRng; + use rand::{Rng, SeedableRng}; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + use std::sync::Arc; + + /// The block preorders are made in. + const PREORDER_HEIGHT: u64 = 10; + + /// The next block, where a domain may reveal a preorder made in + /// [`PREORDER_HEIGHT`]. + const REVEAL_HEIGHT: u64 = PREORDER_HEIGHT + 1; + + /// Block `height`, a second after the one before it. + fn block(height: u64) -> BlockInfo { + BlockInfo { + time_ms: 1_700_000_000_000 + height * 1_000, + height, + ..BlockInfo::default() + } + } + + /// The identity registering names, and another one. + #[derive(Clone, Copy)] + enum Who { + Alice, + Bob, + } + + struct Writer { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The identity contract nonce the next transition uses; every + /// processed transition consumes one, a refused one included. + next_nonce: IdentityNonce, + } + + struct DpnsFixture { + platform: TempPlatform, + platform_version: &'static PlatformVersion, + rng: StdRng, + alice: Writer, + bob: Writer, + } + + impl DpnsFixture { + /// A chain born at `protocol_version` with its genesis state, so the + /// DPNS system contract of that version and its `dash` domain. + fn new(protocol_version: u32) -> Self { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the platform version"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let mut writer = |seed| { + let (identity, signer, key) = + setup_identity(&mut platform, seed, dash_to_credits!(0.5)); + Writer { + identity, + signer, + key, + next_nonce: 1, + } + }; + let alice = writer(5101); + let bob = writer(5102); + + Self { + platform, + platform_version, + rng: StdRng::seed_from_u64(5103), + alice, + bob, + } + } + + fn writer(&mut self, who: Who) -> &mut Writer { + match who { + Who::Alice => &mut self.alice, + Who::Bob => &mut self.bob, + } + } + + fn id(&self, who: Who) -> Identifier { + match who { + Who::Alice => self.alice.identity.id(), + Who::Bob => self.bob.identity.id(), + } + } + + /// The DPNS contract as stored, which is what a write is checked against. + fn dpns(&self) -> DataContract { + self.platform + .drive + .get_contract_with_fetch_info( + SystemDataContract::DPNS.id().to_buffer(), + false, + None, + self.platform_version, + ) + .expect("expected to fetch the DPNS contract") + .expect("expected the DPNS contract in state") + .contract + .clone() + } + + fn process( + &self, + transition: &StateTransition, + height: u64, + ) -> StateTransitionExecutionResult { + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &block(height), + &transaction, + self.platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Creates a `type_name` document owned by `who` in block `height`, + /// with `values` set over the random required ones and `absent` + /// removed, and returns it with the result. The transition carries the + /// document's properties exactly as set: the client builder would + /// regenerate a wrong `generatedFrom` value from protocol version 14. + async fn create( + &mut self, + who: Who, + type_name: &str, + values: &[(&str, Value)], + absent: &[&str], + height: u64, + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = self.platform_version; + let owner_id = self.id(who); + let dpns = self.dpns(); + let document_type = dpns + .document_type_for_name(type_name) + .expect("expected the document type"); + let entropy = Bytes32::random_with_rng(&mut self.rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut self.rng, + owner_id, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in values { + document.set(property, value.clone()); + } + for property in absent { + document.remove(property); + } + let writer = self.writer(who); + let nonce = writer.next_nonce; + writer.next_nonce += 1; + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let mut transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + { + let StateTransition::Batch(batch) = &mut transition else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionMutRef::Document(document_transition)) = + batch.first_transition_mut() + else { + panic!("expected a document transition"); + }; + *document_transition + .data_mut() + .expect("the document transition carries data") = document.properties().clone(); + } + transition + .sign_external( + &writer.key, + &writer.signer, + Some(|_, _| Ok(SecurityLevel::HIGH)), + ) + .await + .expect("expected to sign the transition again"); + let result = self.process(&transition, height); + (document, result) + } + + /// A preorder by `who` in block `height` of `label` under `parent`, + /// salted with `salt`. + async fn preorder( + &mut self, + who: Who, + salt: [u8; 32], + label: &str, + parent: &str, + height: u64, + ) -> Document { + let (preorder, result) = self + .create( + who, + "preorder", + &[( + "saltedDomainHash", + salted_domain_hash(&salt, label, parent).into(), + )], + &[], + height, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the preorder must be accepted" + ); + preorder + } + + /// A domain by `who` in block `height` of `label` under `parent`, + /// revealing `salt`, as a client builds it, with `values` set over it + /// and `absent` removed. + async fn domain( + &mut self, + who: Who, + salt: [u8; 32], + label: &str, + parent: &str, + values: &[(&str, Value)], + absent: &[&str], + height: u64, + ) -> (Document, StateTransitionExecutionResult) { + let mut all_values = vec![ + ("label", Value::Text(label.to_string())), + ("parentDomainName", Value::Text(parent.to_string())), + ( + "normalizedLabel", + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ( + "normalizedParentDomainName", + Value::Text(convert_to_homograph_safe_chars(parent)), + ), + ("preorderSalt", salt.into()), + ("records.identity", self.id(who).into()), + ("subdomainRules.allowSubdomains", false.into()), + ]; + all_values.extend(values.iter().cloned()); + self.create(who, "domain", &all_values, absent, height) + .await + } + + /// Preorders `label` under `dash` in [`PREORDER_HEIGHT`] and reveals it + /// in [`REVEAL_HEIGHT`], both as `who`, with `values` and `absent` + /// applied to the domain. + async fn register( + &mut self, + who: Who, + label: &str, + values: &[(&str, Value)], + absent: &[&str], + ) -> (Document, Document, StateTransitionExecutionResult) { + let salt: [u8; 32] = self.rng.gen(); + let preorder = self + .preorder(who, salt, label, "dash", PREORDER_HEIGHT) + .await; + let (domain, result) = self + .domain(who, salt, label, "dash", values, absent, REVEAL_HEIGHT) + .await; + (preorder, domain, result) + } + + /// The documents of `type_name` with every `(property, value)` pair of + /// `equal`, decoded with the stored DPNS contract. + fn query(&self, type_name: &str, equal: &[(&str, Value)]) -> Vec { + query_dpns(&self.platform, type_name, equal, self.platform_version) + } + + /// The domain `label` under `dash`, if registered. + fn name(&self, label: &str) -> Option { + self.query( + "domain", + &[ + ("normalizedParentDomainName", Value::Text("dash".into())), + ( + "normalizedLabel", + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ], + ) + .pop() + } + + /// The preorder whose salted hash is `preorder`'s, if still stored. + fn stored_preorder(&self, preorder: &Document) -> Option { + let salted_domain_hash = preorder + .properties() + .get("saltedDomainHash") + .expect("expected the preorder's salted hash") + .clone(); + self.query("preorder", &[("saltedDomainHash", salted_domain_hash)]) + .pop() + } + + /// Runs the protocol version 14 activation at block `height` on a + /// chain at protocol version 13, as the first block of version 14 does. + fn activate_protocol_version_14(&mut self, height: u64) { + let platform_version_14 = + PlatformVersion::get(14).expect("expected platform version 14"); + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + self.platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block(height), + &transaction, + 13, + platform_version_14, + ) + .expect("expected the transition to protocol version 14 to succeed"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transition to protocol version 14"); + drop(platform_state); + let mut upgraded_state = self.platform.state.load().as_ref().clone(); + upgraded_state.set_current_protocol_version_in_consensus(14); + upgraded_state.set_next_epoch_protocol_version(14); + self.platform.state.store(Arc::new(upgraded_state)); + self.platform_version = platform_version_14; + } + } + + /// The documents of the stored DPNS contract's `type_name` with every + /// `(property, value)` pair of `equal`. + fn query_dpns( + platform: &TempPlatform, + type_name: &str, + equal: &[(&str, Value)], + platform_version: &PlatformVersion, + ) -> Vec { + let dpns = platform + .drive + .get_contract_with_fetch_info( + SystemDataContract::DPNS.id().to_buffer(), + false, + None, + platform_version, + ) + .expect("expected to fetch the DPNS contract") + .expect("expected the DPNS contract in state") + .contract + .clone(); + let document_type = dpns + .document_type_for_name(type_name) + .expect("expected the document type"); + let drive_query = DriveDocumentQuery { + contract: &dpns, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: equal + .iter() + .map(|(field, value)| { + ( + field.to_string(), + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: value.clone(), + }, + ) + }) + .collect::>(), + }, + offset: None, + limit: None, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + platform + .drive + .query_documents( + drive_query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the documents") + .documents_owned() + } + + /// The commitment a preorder stores, as every client computes it: + /// `sha256d(salt ++ normalizedLabel ++ "." ++ parentDomainName)`. + fn salted_domain_hash(salt: &[u8; 32], label: &str, parent: &str) -> [u8; 32] { + let mut buffer = salt.to_vec(); + buffer.extend(convert_to_homograph_safe_chars(label).as_bytes()); + buffer.extend(b"."); + buffer.extend(parent.as_bytes()); + hash_double(buffer) + } + + /// The consensus error of a refused write, printed with its code. + fn refusal(result: &StateTransitionExecutionResult) -> &ConsensusError { + match result { + StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, + other => panic!("expected a paid consensus error, got {other:?}"), + } + } + + fn assert_data_trigger_refusal(result: &StateTransitionExecutionResult, message: &str) { + assert_matches!( + refusal(result), + ConsensusError::StateError(StateError::DataTriggerError( + DataTriggerError::DataTriggerConditionError(e) + )) if e.message().starts_with(message), + "expected the create trigger to refuse with \"{message}\"" + ); + } + + #[tokio::test] + async fn should_register_a_name_revealing_the_writers_preorder_and_delete_the_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + + // The client leaves both normalized names out: the platform generates them + let (preorder, _, result) = fixture + .register( + Who::Alice, + "Quantum7", + &[], + &["normalizedLabel", "normalizedParentDomainName"], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let name = fixture.name("Quantum7").expect("expected the name"); + assert_eq!(name.owner_id(), fixture.id(Who::Alice)); + assert_eq!( + name.properties() + .get_str("normalizedLabel") + .expect("expected the generated normalized label"), + "quantum7" + ); + assert_eq!( + name.properties() + .get_str("normalizedParentDomainName") + .expect("expected the generated normalized parent"), + "dash" + ); + assert!( + fixture.stored_preorder(&preorder).is_none(), + "the create consumes the preorder" + ); + } + + #[tokio::test] + async fn should_refuse_a_reveal_in_the_block_of_its_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let salt: [u8; 32] = fixture.rng.gen(); + let preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + PREORDER_HEIGHT, + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedDocumentRequirementNotMetError(e)) + if e.field() == "minimumAgeBlocks" + ); + assert!(fixture.name("quantum7").is_none()); + assert!(fixture.stored_preorder(&preorder).is_some()); + + // The next block reveals it + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_refuse_a_reveal_of_another_identitys_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let salt: [u8; 32] = fixture.rng.gen(); + let preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + + // Bob read Alice's salt off her domain create and races it + let (_, result) = fixture + .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedDocumentPropertyMismatchError(_)) + ); + assert!(fixture.name("quantum7").is_none()); + assert!(fixture.stored_preorder(&preorder).is_some()); + } + + #[tokio::test] + async fn should_refuse_a_name_without_its_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let salt: [u8; 32] = fixture.rng.gen(); + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) + ); + assert!(fixture.name("quantum7").is_none()); + } + + #[tokio::test] + async fn should_refuse_a_normalized_label_other_than_the_one_generated() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + + // Lowercased, but "o" not replaced by "0" + let (preorder, _, result) = fixture + .register( + Who::Alice, + "Bob7x", + &[("normalizedLabel", Value::Text("bob7x".into()))], + &[], + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e)) + if e.property() == "normalizedLabel" + ); + assert!(fixture.name("Bob7x").is_none()); + assert!(fixture.stored_preorder(&preorder).is_some()); + } + + #[tokio::test] + async fn should_refuse_a_name_whose_identity_record_is_not_its_owner() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let bob = fixture.id(Who::Bob); + + let (preorder, _, result) = fixture + .register( + Who::Alice, + "quantum7", + &[("records.identity", bob.into())], + &[], + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(e)) + if e.constraint() == "recordsIdentityIsOwner" + ); + assert!(fixture.name("quantum7").is_none()); + assert!(fixture.stored_preorder(&preorder).is_some()); + } + + /// The create trigger keeps the parent domain checks at protocol version 14: + /// each case reveals a preorder made for it, so only the trigger refuses it. + #[tokio::test] + async fn should_keep_the_parent_domain_checks_in_the_create_trigger() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + + // A parent domain nobody registered + let salt: [u8; 32] = fixture.rng.gen(); + fixture + .preorder(Who::Alice, salt, "quantum7", "n0where7", PREORDER_HEIGHT) + .await; + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "n0where7", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_data_trigger_refusal(&result, "Parent domain is not present"); + + // A name under `dash` opening subdomains + let salt: [u8; 32] = fixture.rng.gen(); + fixture + .preorder(Who::Alice, salt, "quantum8", "dash", PREORDER_HEIGHT) + .await; + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum8", + "dash", + &[("subdomainRules.allowSubdomains", true.into())], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_data_trigger_refusal( + &result, + "Allowing subdomains registration is forbidden for this domain", + ); + + // A top-level domain by anyone but the DPNS contract owner + let salt: [u8; 32] = fixture.rng.gen(); + fixture + .preorder(Who::Alice, salt, "quantum9", "", PREORDER_HEIGHT) + .await; + let (_, result) = fixture + .domain(Who::Alice, salt, "quantum9", "", &[], &[], REVEAL_HEIGHT) + .await; + assert_data_trigger_refusal(&result, "Can't create top level domain for this identity"); + assert_ne!(fixture.id(Who::Alice), dpns_contract::OWNER_ID); + } + + /// Before protocol version 14 the whole create trigger runs on DPNS v2: a + /// missing preorder and a wrong normalized label are its refusals, and it + /// neither deletes the preorder nor reads the identity record. + #[tokio::test] + async fn should_keep_the_create_trigger_at_protocol_version_13() { + let mut fixture = DpnsFixture::new(13); + + let salt: [u8; 32] = fixture.rng.gen(); + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_data_trigger_refusal(&result, "preorderDocument was not found"); + + let (_, _, result) = fixture + .register( + Who::Alice, + "Bob8x", + &[("normalizedLabel", Value::Text("b0b8y".into()))], + &[], + ) + .await; + assert_data_trigger_refusal(&result, "Normalized label doesn't match label"); + + let bob = fixture.id(Who::Bob); + let (preorder, _, result) = fixture + .register( + Who::Alice, + "quantum9", + &[("records.identity", bob.into())], + &[], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!( + fixture.stored_preorder(&preorder).is_some(), + "the trigger leaves the preorder in place" + ); + } + + /// A transfer is judged against `recordsIdentityIsOwner` with its own time: + /// in a later block than the create it no longer reads the identity + /// record, which Drive then points at the new owner; in the block that + /// created the name it is judged as the create was, and refused. + #[tokio::test] + async fn should_transfer_a_name_in_a_later_block_but_not_in_the_block_that_created_it() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let bob = fixture.id(Who::Bob); + let (_, mut name, result) = fixture.register(Who::Alice, "quantum7", &[], &[]).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + name.set_revision(Some(2)); + + let dpns = fixture.dpns(); + let domain = dpns + .document_type_for_name("domain") + .expect("expected the domain document type"); + let transfer = |nonce| { + let writer = &fixture.alice; + BatchTransition::new_document_transfer_transition_from_document( + name.clone(), + domain, + bob, + &writer.key, + nonce, + 0, + None, + &writer.signer, + fixture.platform_version, + None, + ) + }; + let same_block = transfer(fixture.alice.next_nonce) + .await + .expect("expected the transfer transition"); + let later_block = transfer(fixture.alice.next_nonce + 1) + .await + .expect("expected the transfer transition"); + fixture.alice.next_nonce += 2; + + let result = fixture.process(&same_block, REVEAL_HEIGHT); + assert_matches!( + refusal(&result), + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(e)) + if e.constraint() == "recordsIdentityIsOwner" + ); + + let result = fixture.process(&later_block, REVEAL_HEIGHT + 1); + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let name = fixture.name("quantum7").expect("expected the name"); + assert_eq!(name.owner_id(), bob); + assert_eq!( + name.get("records.identity"), + Some(&Value::Identifier(bob.to_buffer())) + ); + } + + /// A chain upgraded from protocol version 13: DPNS is re-stored as v3, + /// every domain and preorder written under v2 decodes unchanged, and a + /// preorder made before the upgrade, which records no creation height, is + /// old enough to reveal. + #[tokio::test] + async fn should_decode_v2_documents_and_reveal_a_v2_preorder_after_the_upgrade() { + let mut fixture = DpnsFixture::new(13); + + let (_, registered, result) = fixture.register(Who::Bob, "quantum7", &[], &[]).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let salt: [u8; 32] = fixture.rng.gen(); + let pending = fixture + .preorder(Who::Alice, salt, "quantum8", "dash", REVEAL_HEIGHT) + .await; + + let v2_domain = fixture.name("quantum7").expect("expected the v2 domain"); + let v2_preorder = fixture + .stored_preorder(&pending) + .expect("expected the v2 preorder"); + assert_eq!(v2_domain.id(), registered.id()); + assert_eq!(v2_preorder.created_at_block_height(), None); + let v2_dpns = fixture.dpns(); + assert!(v2_dpns + .document_type_for_name("domain") + .expect("expected the domain document type") + .documents_can_be_deleted()); + + fixture.activate_protocol_version_14(20); + + let v3_dpns = fixture.dpns(); + let v3_domain_type = v3_dpns + .document_type_for_name("domain") + .expect("expected the domain document type"); + assert!(!v3_domain_type.documents_can_be_deleted()); + assert!(v3_dpns + .document_type_for_name("preorder") + .expect("expected the preorder document type") + .required_fields() + .contains("$createdAtBlockHeight")); + assert_eq!( + fixture.name("quantum7").expect("expected the domain"), + v2_domain, + "a domain written under v2 decodes unchanged under v3" + ); + assert_eq!( + fixture + .stored_preorder(&pending) + .expect("expected the preorder"), + v2_preorder, + "a preorder written under v2 decodes unchanged under v3" + ); + + let (_, result) = fixture + .domain(Who::Alice, salt, "quantum8", "dash", &[], &[], 21) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(fixture.name("quantum8").is_some()); + assert!(fixture.stored_preorder(&pending).is_none()); + + // A preorder made from protocol version 14 on records its height + let salt: [u8; 32] = fixture.rng.gen(); + let preorder = fixture + .preorder(Who::Alice, salt, "quantum9", "dash", 22) + .await; + assert_eq!( + fixture + .stored_preorder(&preorder) + .expect("expected the preorder") + .created_at_block_height(), + Some(22) + ); + } + + /// A chain born at protocol version 14 stores DPNS v3 and still inserts + /// the `dash` top-level domain, which `allowSubdomains` and whose owner is + /// the DPNS contract owner. + #[tokio::test] + async fn should_store_dpns_v3_and_the_dash_domain_at_genesis() { + let fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let dpns = fixture.dpns(); + assert!(!dpns + .document_type_for_name("domain") + .expect("expected the domain document type") + .documents_can_be_deleted()); + + let dash = fixture + .query( + "domain", + &[ + ("normalizedParentDomainName", Value::Text(String::new())), + ("normalizedLabel", Value::Text("dash".into())), + ], + ) + .pop() + .expect("expected the dash top-level domain"); + assert_eq!( + dash.id(), + Identifier::new(dpns_contract::DPNS_DASH_TLD_DOCUMENT_ID) + ); + assert_eq!(dash.owner_id(), dpns_contract::OWNER_ID); + assert!(dash + .properties() + .get_bool_at_path("subdomainRules.allowSubdomains") + .expect("expected the subdomain rule")); + } + + /// A contested name at protocol version 14: the create that joins the + /// contest is judged by the rules and reveals and consumes its preorder + /// there, though the name is only stored once the contest is awarded. + #[tokio::test] + async fn should_check_a_contested_name_at_join_and_consume_its_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let bob = fixture.id(Who::Bob); + + // "quantum" matches the contested regex + let (preorder, _, result) = fixture + .register( + Who::Alice, + "quantum", + &[("records.identity", bob.into())], + &[], + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(e)) + if e.constraint() == "recordsIdentityIsOwner" + ); + assert!(fixture.stored_preorder(&preorder).is_some()); + + let (preorder, _, result) = fixture.register(Who::Alice, "quantum", &[], &[]).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!( + fixture.stored_preorder(&preorder).is_none(), + "joining the contest consumes the preorder" + ); + assert!( + fixture.name("quantum").is_none(), + "the name waits for the contest" + ); + } + + /// Two contenders open a contest on a name at protocol version 14, each + /// consuming its own preorder, and the award stores the winner's name. + #[tokio::test] + async fn should_award_a_contested_name_to_its_winner() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + + let ( + (contender_1, _, _, (preorder_1, _), _), + (_contender_2, _, _, (preorder_2, _), _), + dpns_contract, + ) = create_dpns_name_contest_give_key_info( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + for preorder in [&preorder_1, &preorder_2] { + let salted_domain_hash = preorder + .properties() + .get("saltedDomainHash") + .expect("expected the preorder's salted hash") + .clone(); + assert!( + query_dpns( + &platform, + "preorder", + &[("saltedDomainHash", salted_domain_hash)], + platform_version, + ) + .is_empty(), + "each join consumes its contender's preorder" + ); + } + + perform_votes_multi( + &mut platform, + dpns_contract.as_ref(), + vec![(TowardsIdentity(contender_1.id()), 1)], + "quantum", + 10, + None, + platform_version, + ) + .await; + + // Two weeks and 300 seconds on, past the contest's end + let block_info = BlockInfo { + time_ms: 1_209_900_000, + height: 10_000, + core_height: 42, + epoch: Default::default(), + }; + let mut ended_state = platform.state.load().as_ref().clone(); + ended_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected the root hash"), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(ended_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected to check for ended vote polls"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + + let name = query_dpns( + &platform, + "domain", + &[ + ("normalizedParentDomainName", Value::Text("dash".into())), + ("normalizedLabel", Value::Text("quantum".into())), + ], + platform_version, + ) + .pop() + .expect("expected the awarded name"); + assert_eq!(name.owner_id(), contender_1.id()); + assert_eq!( + name.get("records.identity"), + Some(&Value::Identifier(contender_1.id().to_buffer())) + ); + } + + /// What a client pays for a name: the domain create at protocol version 13 + /// (create trigger, preorder kept) and at 14 (keywords, preorder consumed + /// and its storage refunded to the writer). Printed for the record and + /// pinned. + #[tokio::test] + async fn should_charge_a_domain_create_at_protocol_versions_13_and_14() { + let mut measured = vec![]; + for protocol_version in [13, 14] { + let mut fixture = DpnsFixture::new(protocol_version); + let alice = fixture.id(Who::Alice); + let (_, _, result) = fixture.register(Who::Alice, "quantum7", &[], &[]).await; + let StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } = result + else { + panic!("expected the domain create to succeed, got {result:?}"); + }; + let refund = fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(alice) + .unwrap_or_default(); + println!( + "protocol version {protocol_version}: processing fee {}, storage fee {}, \ + refund {refund}", + fee_result.processing_fee, fee_result.storage_fee + ); + measured.push((fee_result.processing_fee, fee_result.storage_fee, refund)); + } + assert_eq!( + measured, + vec![ + (1_876_280, 42_660_000, 0), + (2_318_400, 42_687_000, 15_181_239) + ] + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 39b52daa2de..79d66d96746 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -10,6 +10,7 @@ mod derived_index_values; mod distinct_from; mod document_ttl; mod dpns; +mod dpns_v3; mod duplicate_keys; mod encrypted_for; mod gas_sponsorship; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs index 50c8891a575..282e6cbd02a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs @@ -621,11 +621,16 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { .await .expect("expected to sign the contested create"); + // The block after the preorder's: from protocol version 14 a domain reveals a preorder + // made in an earlier block process_and_commit_at( &platform, &platform_state, &create, - &BlockInfo::default_with_time(3000), + &BlockInfo { + height: 1, + ..BlockInfo::default_with_time(3000) + }, platform_version, ); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 71d3753fbe2..d6e3a6bc7da 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -1241,8 +1241,12 @@ pub(in crate::execution) mod tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is disabled with data trigger - assert!(domain.documents_can_be_deleted()); + // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` + // from DPNS v3 (protocol version 14) + assert_eq!( + domain.documents_can_be_deleted(), + platform_version.system_data_contracts.dpns < 3 + ); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -1494,6 +1498,8 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); + // One block after the preorders: from protocol version 14 a domain reveals a + // preorder made in an earlier block let processing_result = platform .platform .process_raw_state_transitions( @@ -1502,12 +1508,15 @@ pub(in crate::execution) mod tests { documents_batch_create_serialized_transition_2.clone(), ], platform_state, - &BlockInfo::default_with_time( - platform_state - .last_committed_block_time_ms() - .unwrap_or_default() - + 3000, - ), + &BlockInfo { + height: 1, + ..BlockInfo::default_with_time( + platform_state + .last_committed_block_time_ms() + .unwrap_or_default() + + 3000, + ) + }, &transaction, platform_version, false, @@ -1806,6 +1815,8 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); + // One block after the preorders: from protocol version 14 a domain reveals a + // preorder made in an earlier block let processing_result = platform .platform .process_raw_state_transitions( @@ -1814,12 +1825,15 @@ pub(in crate::execution) mod tests { documents_batch_create_serialized_transition_2.clone(), ], platform_state, - &BlockInfo::default_with_time( - platform_state - .last_committed_block_time_ms() - .unwrap_or_default() - + 3000, - ), + &BlockInfo { + height: 1, + ..BlockInfo::default_with_time( + platform_state + .last_committed_block_time_ms() + .unwrap_or_default() + + 3000, + ) + }, &transaction, platform_version, false, @@ -2063,17 +2077,22 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); + // One block after the preorder: from protocol version 14 a domain reveals a + // preorder made in an earlier block let processing_result = platform .platform .process_raw_state_transitions( &[documents_batch_create_serialized_transition_1.clone()], platform_state, - &BlockInfo::default_with_time( - platform_state - .last_committed_block_time_ms() - .unwrap_or_default() - + 3000, - ), + &BlockInfo { + height: 1, + ..BlockInfo::default_with_time( + platform_state + .last_committed_block_time_ms() + .unwrap_or_default() + + 3000, + ) + }, &transaction, platform_version, false, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index 908903a4767..15062fa5c9e 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -28,7 +28,11 @@ mod tests { use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0Getters; use dpp::dash_to_duffs; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::DataContract; + use dpp::platform_value::Identifier; use dpp::state_transition::StateTransition; + use dpp::util::hash::hash_double; + use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use drive::util::object_size_info::DataContractOwnedResolvedInfo; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; @@ -41,6 +45,143 @@ mod tests { const STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB + /// The `preorder` op of `owner_id` for `label` under `dash` salted with `salt`, and the + /// `domain` op revealing it. From protocol version 14 a domain must reveal its owner's + /// preorder made in an earlier block, so the preorder op runs in a block before the domain op. + fn dpns_preorder_and_domain_ops( + dpns_contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + ) -> (DocumentOp, DocumentOp) { + let normalized_label = convert_to_homograph_safe_chars(label); + // sha256d(salt ++ normalizedLabel ++ "." ++ parentDomainName) + let mut salted_domain_name = salt.to_vec(); + salted_domain_name.extend(normalized_label.as_bytes()); + salted_domain_name.extend(b".dash"); + + let preorder_op = DocumentOp { + contract: dpns_contract.clone(), + action: DocumentAction::DocumentActionInsertSpecific( + BTreeMap::from([( + "saltedDomainHash".into(), + hash_double(salted_domain_name).into(), + )]), + Some(owner_id), + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: dpns_contract + .document_type_for_name("preorder") + .expect("expected the preorder document type") + .to_owned_document_type(), + }; + + let domain_op = DocumentOp { + contract: dpns_contract.clone(), + action: DocumentAction::DocumentActionInsertSpecific( + BTreeMap::from([ + ("label".into(), label.into()), + ("normalizedLabel".into(), normalized_label.into()), + ("parentDomainName".into(), "dash".into()), + ("normalizedParentDomainName".into(), "dash".into()), + ("preorderSalt".into(), salt.into()), + ( + "records".into(), + BTreeMap::from([("identity", Value::from(owner_id))]).into(), + ), + ]), + Some(owner_id), + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: dpns_contract + .document_type_for_name("domain") + .expect("expected the domain document type") + .to_owned_document_type(), + }; + + (preorder_op, domain_op) + } + + /// `document_op` run once in every block of its stage + fn once_per_block(document_op: DocumentOp) -> Operation { + Operation { + op_type: OperationType::Document(document_op), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + } + } + + /// Runs the block after `outcome` with `operations`, carrying on the identities, nonces and + /// proposer versions it ended with + async fn continue_chain_for_one_block<'a>( + outcome: ChainExecutionOutcome<'a>, + operations: Vec, + config: PlatformConfig, + ) -> ChainExecutionOutcome<'a> { + let block_start = outcome + .abci_app + .platform + .state + .load() + .last_committed_block_info() + .as_ref() + .expect("expected a committed block") + .basic_info() + .height + + 1; + + let ChainExecutionOutcome { + abci_app, + identities, + addresses_with_balance, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions, + instant_lock_quorums, + identity_nonce_counter, + identity_contract_nonce_counter, + end_time_ms, + strategy, + .. + } = outcome; + + continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start, + core_height_start: 1, + block_count: 1, + proposers, + validator_quorums, + current_validator_quorum_hash, + instant_lock_quorums, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: strategy.start_time_ms, + current_time_ms: end_time_ms, + current_identities: identities, + current_addresses_with_balance: addresses_with_balance, + }, + NetworkStrategy { + strategy: Strategy { + operations, + ..strategy.strategy + }, + ..strategy + }, + config, + StrategyRandomness::SeedEntropy(7), + ) + .await + } + #[stack_size(STACK_SIZE)] #[test] async fn run_chain_accepts_contested_documents_before_epoch_4() { @@ -105,41 +246,14 @@ mod tests { .as_ref() .clone(); - let document_type = dpns_contract - .document_type_for_name("domain") - .expect("expected a profile document type") - .to_owned_document_type(); - let identity1_id = start_identities.first().unwrap().0.id(); - let document_op_1 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([("identity", Value::from(identity1_id))]).into(), - ), - ]), - Some(start_identities.first().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; + let (preorder_op_1, domain_op_1) = + dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); let strategy = NetworkStrategy { strategy: Strategy { start_contracts: vec![], - operations: vec![Operation { - op_type: OperationType::Document(document_op_1.clone()), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }], + operations: vec![once_per_block(preorder_op_1)], start_identities: StartIdentities { hard_coded: start_identities.clone(), ..Default::default() @@ -164,34 +278,36 @@ mod tests { let mut voting_signer = Some(SimpleSigner::default()); - let ChainExecutionOutcome { - abci_app, - state_transition_results_per_block, - .. - } = run_chain_for_strategy( + let outcome = run_chain_for_strategy( &mut platform, 2, strategy, - config, + config.clone(), 15, &mut voting_signer, &mut None, ) .await; + let ChainExecutionOutcome { + abci_app, + state_transition_results_per_block, + .. + } = continue_chain_for_one_block(outcome, vec![once_per_block(domain_op_1)], config).await; + let platform_state = abci_app.platform.state.load(); - // The identity is created in the first block and the contested DPNS name in the second, - // long before epoch 4 - assert_eq!(platform_state.last_committed_block_epoch().index, 1); + // The identity is created in the first block, its preorder in the second and the + // contested DPNS name in the third, long before epoch 4 + assert_eq!(platform_state.last_committed_block_epoch().index, 2); - let state_transitions_block_2 = state_transition_results_per_block - .get(&2) - .expect("expected to get block 2"); + let state_transitions_block_3 = state_transition_results_per_block + .get(&3) + .expect("expected to get block 3"); - assert_eq!(state_transitions_block_2.len(), 1); + assert_eq!(state_transitions_block_3.len(), 1); - let (state_transition, execution_result) = state_transitions_block_2 + let (state_transition, execution_result) = state_transitions_block_3 .first() .expect("expected a document insert"); @@ -550,67 +666,15 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let document_op_1 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([("identity", Value::from(identity1_id))]).into(), - ), - ]), - Some(start_identities.first().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; - - let document_op_2 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([( - "identity", - Value::from(start_identities.last().unwrap().0.id()), - )]) - .into(), - ), - ]), - Some(start_identities.last().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; + let (preorder_op_1, domain_op_1) = + dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); + let (preorder_op_2, domain_op_2) = + dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); let strategy = NetworkStrategy { strategy: Strategy { start_contracts: vec![], - operations: vec![ - Operation { - op_type: OperationType::Document(document_op_1), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - Operation { - op_type: OperationType::Document(document_op_2), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - ], + operations: vec![once_per_block(preorder_op_1), once_per_block(preorder_op_2)], start_identities: StartIdentities { hard_coded: start_identities, ..Default::default() @@ -636,7 +700,19 @@ mod tests { let mut voting_signer = Some(SimpleSigner::default()); - // On the first block we only have identities and contracts + // The first block creates the identities and the second their preorders + let outcome = run_chain_for_strategy( + &mut platform, + 2, + strategy.clone(), + config.clone(), + 15, + &mut voting_signer, + &mut None, + ) + .await; + + // The contested names reveal the preorders in the third block let ChainExecutionOutcome { abci_app, proposers, @@ -649,14 +725,10 @@ mod tests { identity_contract_nonce_counter, state_transition_results_per_block, .. - } = run_chain_for_strategy( - &mut platform, - 2, - strategy.clone(), + } = continue_chain_for_one_block( + outcome, + vec![once_per_block(domain_op_1), once_per_block(domain_op_2)], config.clone(), - 15, - &mut voting_signer, - &mut None, ) .await; @@ -664,18 +736,18 @@ mod tests { let platform_state = platform.state.load(); - let state_transitions_block_2 = state_transition_results_per_block - .get(&2) - .expect("expected to get block 2"); + let state_transitions_block_3 = state_transition_results_per_block + .get(&3) + .expect("expected to get block 3"); - let first_document_insert_result = &state_transitions_block_2 + let first_document_insert_result = &state_transitions_block_3 .first() .as_ref() .expect("expected a document insert") .1; assert_eq!(first_document_insert_result.code, 0); - let second_document_insert_result = &state_transitions_block_2 + let second_document_insert_result = &state_transitions_block_3 .get(1) .as_ref() .expect("expected a document insert") @@ -909,67 +981,15 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let document_op_1 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([("identity", Value::from(identity1_id))]).into(), - ), - ]), - Some(start_identities.first().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; - - let document_op_2 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([( - "identity", - Value::from(start_identities.last().unwrap().0.id()), - )]) - .into(), - ), - ]), - Some(start_identities.last().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; + let (preorder_op_1, domain_op_1) = + dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); + let (preorder_op_2, domain_op_2) = + dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); let strategy = NetworkStrategy { strategy: Strategy { start_contracts: vec![], - operations: vec![ - Operation { - op_type: OperationType::Document(document_op_1), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - Operation { - op_type: OperationType::Document(document_op_2), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - ], + operations: vec![once_per_block(preorder_op_1), once_per_block(preorder_op_2)], start_identities: StartIdentities { hard_coded: start_identities, ..Default::default() @@ -995,7 +1015,19 @@ mod tests { let mut voting_signer = Some(SimpleSigner::default()); - // On the first block we only have identities and contracts + // The first block creates the identities and the second their preorders + let outcome = run_chain_for_strategy( + &mut platform, + 2, + strategy.clone(), + config.clone(), + 15, + &mut voting_signer, + &mut None, + ) + .await; + + // The contested names reveal the preorders in the third block let ChainExecutionOutcome { abci_app, proposers, @@ -1008,14 +1040,10 @@ mod tests { identity_contract_nonce_counter, state_transition_results_per_block, .. - } = run_chain_for_strategy( - &mut platform, - 2, - strategy.clone(), + } = continue_chain_for_one_block( + outcome, + vec![once_per_block(domain_op_1), once_per_block(domain_op_2)], config.clone(), - 15, - &mut voting_signer, - &mut None, ) .await; @@ -1023,18 +1051,18 @@ mod tests { let platform_state = platform.state.load(); - let state_transitions_block_2 = state_transition_results_per_block - .get(&2) - .expect("expected to get block 2"); + let state_transitions_block_3 = state_transition_results_per_block + .get(&3) + .expect("expected to get block 3"); - let first_document_insert_result = &state_transitions_block_2 + let first_document_insert_result = &state_transitions_block_3 .first() .as_ref() .expect("expected a document insert") .1; assert_eq!(first_document_insert_result.code, 0); - let second_document_insert_result = &state_transitions_block_2 + let second_document_insert_result = &state_transitions_block_3 .get(1) .as_ref() .expect("expected a document insert") @@ -1726,67 +1754,15 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let document_op_1 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([("identity", Value::from(identity1_id))]).into(), - ), - ]), - Some(start_identities.first().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; - - let document_op_2 = DocumentOp { - contract: dpns_contract.clone(), - action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), "quantum".into()), - ("normalizedLabel".into(), "quantum".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ( - "records".into(), - BTreeMap::from([( - "identity", - Value::from(start_identities.last().unwrap().0.id()), - )]) - .into(), - ), - ]), - Some(start_identities.last().unwrap().0.id()), - DocumentFieldFillType::FillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - ), - document_type: document_type.clone(), - }; + let (preorder_op_1, domain_op_1) = + dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); + let (preorder_op_2, domain_op_2) = + dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); let strategy = NetworkStrategy { strategy: Strategy { start_contracts: vec![], - operations: vec![ - Operation { - op_type: OperationType::Document(document_op_1), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - Operation { - op_type: OperationType::Document(document_op_2), - frequency: Frequency { - times_per_block_range: 1..2, - chance_per_block: None, - }, - }, - ], + operations: vec![once_per_block(preorder_op_1), once_per_block(preorder_op_2)], start_identities: StartIdentities { hard_coded: start_identities, ..Default::default() @@ -1811,7 +1787,19 @@ mod tests { let mut voting_signer = Some(SimpleSigner::default()); - // On the first block we only have identities and contracts + // The first block creates the identities and the second their preorders + let outcome = run_chain_for_strategy( + &mut platform, + 2, + strategy.clone(), + config.clone(), + 15, + &mut voting_signer, + &mut None, + ) + .await; + + // The contested names reveal the preorders in the third block let ChainExecutionOutcome { abci_app, proposers, @@ -1824,14 +1812,10 @@ mod tests { identity_contract_nonce_counter, state_transition_results_per_block, .. - } = run_chain_for_strategy( - &mut platform, - 2, - strategy.clone(), + } = continue_chain_for_one_block( + outcome, + vec![once_per_block(domain_op_1), once_per_block(domain_op_2)], config.clone(), - 15, - &mut voting_signer, - &mut None, ) .await; @@ -1839,18 +1823,18 @@ mod tests { let platform_state = platform.state.load(); - let state_transitions_block_2 = state_transition_results_per_block - .get(&2) - .expect("expected to get block 2"); + let state_transitions_block_3 = state_transition_results_per_block + .get(&3) + .expect("expected to get block 3"); - let first_document_insert_result = &state_transitions_block_2 + let first_document_insert_result = &state_transitions_block_3 .first() .as_ref() .expect("expected a document insert") .1; assert_eq!(first_document_insert_result.code, 0); - let second_document_insert_result = &state_transitions_block_2 + let second_document_insert_result = &state_transitions_block_3 .get(1) .as_ref() .expect("expected a document insert") @@ -2032,14 +2016,16 @@ mod tests { assert_eq!(abstain_vote_tally, Some(8)); + // The names were registered in block 3, behind their preorders, so the contest ends + // in block 18 assert_eq!( finished_vote_info, Some(FinishedVoteInfo { finished_vote_outcome: FinishedVoteOutcome::TowardsIdentity.into(), won_by_identity_id: Some(identity2_id.to_vec()), - finished_at_block_height: 17, + finished_at_block_height: 18, finished_at_core_block_height: 1, - finished_at_block_time_ms: 1682303986000, + finished_at_block_time_ms: 1682303989000, finished_at_epoch: 1 }) ); diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 28bf44e3d62..165838a981d 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -222,7 +222,10 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { // PROTOCOL_VERSION_14: v2 adds DashPay `profile` // Create/Replace triggers enforcing the DIP-33 - // payment-address type byte (0x00 P2PKH / 0x01 P2SH). + // payment-address type byte (0x00 P2PKH / 0x01 P2SH), + // and drops the reject bindings on DPNS `domain` Replace + // and Delete, which DPNS v3 refuses with + // `documentsMutable` and `canBeDeleted` false. // It keeps v1's PROTOCOL_VERSION_13 change: no reject // bindings for Transfer, Purchase and UpdatePrice on // DPNS `domain` documents (username transfers/sales). @@ -235,7 +238,10 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // byte-identical to PV11 (don't bill). create_contact_request_data_trigger: 1, validate_profile_payment_addresses_data_trigger: 0, - create_domain_data_trigger: 1, + // PROTOCOL_VERSION_14: v2 keeps only the parent + // domain checks; DPNS v3's schema keywords check the + // rest of a domain create. + create_domain_data_trigger: 2, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, create_masternode_reward_shares_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index 5bdeac3b0b4..e97ae8897d4 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -8,8 +8,12 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // withdrawals whose asset unlock Core can never mine. The token history // contract v2 admits the value 2 (OncePerIdentity) of the claim document's // `distributionType`, written for once-per-identity distribution claims. -// v2 (dashpay: 1, withdrawals: 1, token_history: 1) remains for -// PROTOCOL_VERSION_13 chain replay. +// The DPNS contract v3 checks a domain create with schema keywords instead of +// most of its data trigger: generated normalized names, the reveal and +// deletion of the writer's own preorder from an earlier block, and the new +// domain's identity record held to its owner. Domains can no longer be +// deleted. v2 (dashpay: 1, withdrawals: 1, token_history: 1, dpns: 2) remains +// for PROTOCOL_VERSION_13 chain replay. // // The app-connect contract (app_connect: 1) also activates with // PROTOCOL_VERSION_14: it is registered at genesis from that version on and @@ -24,7 +28,7 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, - dpns: 2, + dpns: 3, dashpay: 2, masternode_reward_shares: 1, wallet: 1, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7ed82ee63a7..62e089bdd25 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1616,7 +1616,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// function the reference may declare `minimumAgeBlocks`, judged by /// document create state validation 2 against the found document's /// `$createdAtBlockHeight` (`ReferencedDocumentRequirementNotMetError`, -/// 40142), and `consume`, which deletes the found document with the create +/// 40142; a found document recording none counts as old enough, 87), and +/// `consume`, which deletes the found document with the create /// (`DocumentCreateTransitionAction` `consumed_documents`, a batch touching /// it elsewhere refused with 40120). The hash is computed once per key and /// billed as `ValidationOperation::DoubleSha256` by the blocks it hashes, @@ -1640,8 +1641,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `deletableDocument` target through a function, and the `findBy` of an /// `ownerRefersTo` or `creatorRefersTo` may leave the value out beside /// one. The declaration reproduces the DPNS preorder hash of a name under -/// a parent byte for byte; the DPNS contract and its create trigger are -/// unchanged. See `book/src/data-model/documents.md`. +/// a parent byte for byte, and DPNS v3 declares it (87). See +/// `book/src/data-model/documents.md`. /// /// 62. **Null flags follow each index's own path**: the v2 index-level /// insert and delete walkers give each sub-level the null flags of its @@ -2212,6 +2213,41 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// schemas without a scalar `$ref` target; earlier versions replay /// through generation 0. /// +/// 87. **DPNS v3 checks a name's create with keywords**: +/// `SYSTEM_DATA_CONTRACT_VERSIONS_V3` selects DPNS contract v3, loaded at +/// genesis (which still inserts the `dash` top-level domain) and re-stored +/// by `transition_to_version_14` with `apply_contract`. Its `domain` +/// generates `normalizedLabel` and `normalizedParentDomainName` with +/// `sys.stringTransformations.homographSafeASCII` (53; a value sent that +/// differs is `DocumentPropertyNotGeneratedError`, 10424). Its +/// `preorderSalt` reveals the writer's own `preorder` from an earlier block +/// and deletes it, the storage refunded to the writer (61: `findBy` the +/// sha256d of the salt, the normalized label, `"."` and the parent, +/// `where` `$ownerId`, `minimumAgeBlocks: 1`, `consume`; refused with 40120, +/// 40127 or 40142). The rule `recordsIdentityIsOwner` (`$transferredAt` +/// equal to `$createdAt` implies `records.identity` equal to `$ownerId`) +/// holds a new name's identity record to its owner (10422); a transfer or +/// a purchase in a later block, judged with its own time, no longer reads +/// the record, and one in the block that created the name is refused. The +/// `domain` sets `canBeDeleted: false` and the `preorder` requires +/// `$createdAtBlockHeight`; property positions, the transient list and the +/// stored encoding are v2's, so stored domains and preorders decode +/// unchanged. `create_domain_data_trigger` 2 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`) keeps only the parent domain +/// checks: a top-level domain only by the contract owner, the parent +/// present, no subdomains allowed under a name, and the parent's subdomain +/// rule. Data trigger bindings 2, in place, drop the `domain` Replace and +/// Delete rejects; `documentsMutable` and `canBeDeleted` false refuse both +/// (`InvalidDocumentTransitionActionError`, 10404). The reference +/// validation 0, in place, counts a found document recording no +/// `$createdAtBlockHeight` as old enough for `minimumAgeBlocks`: an update +/// can not make a system field required, so only a system contract a +/// protocol upgrade re-stored holds such documents, all written before the +/// upgrade's block, and the preorders made before this version stay +/// revealable. Inert before this version: only the version 14 parser +/// produces a `minimumAgeBlocks`, and the earlier tables select DPNS v1 or +/// v2, trigger 0 or 1 and bindings 0 or 1. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -2318,7 +2354,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, // changed: daily_withdrawal_limit v2 — a percentage of the total credits a day ago; credit_pool_bundle_binding Some(0) — the credit pool's outputs-only bundles bind a kind tag and their owner factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33); withdrawals v2 admits the terminal FAILED status + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33); withdrawals v2 admits the terminal FAILED status; DPNS v3 checks a name's create with keywords // The TTL ephemeral-bytes rate (270 credits/byte to processing) rides // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index b0e594bc082..8190ed30bb2 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -81,7 +81,8 @@ pub struct RegisterDpnsNameInput> { /// Result of a DPNS name registration #[derive(Debug)] pub struct RegisterDpnsNameResult { - /// The preorder document that was created + /// The preorder document that was created. From protocol version 14 the domain + /// create deletes it, so it is no longer in state once the name is registered pub preorder_document: Document, /// The domain document that was created pub domain_document: Document, From 08a56796afdffdf363be935cc8e001b91a79a3c8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 8 Oct 2026 18:37:15 +0200 Subject: [PATCH 02/62] fix(platform): a pre-upgrade preorder meets only a one-block minimum age A document recording no `$createdAtBlockHeight` is known only to come from a block before the protocol upgrade that re-stored its system contract, so it now meets `minimumAgeBlocks: 1` (the DPNS reveal's) and no larger minimum. Another contract revealing DPNS preorders with a larger minimum refuses a pre-upgrade preorder; a test covers it. Also: a trigger-refused DPNS reveal keeps its preorder for a retry (test), and the check_tx contract update fee pin follows the DPNS v3 preorder type the test copies in. Co-Authored-By: Claude Opus 5.5 --- .../src/contract-keywords/refers-to-lookup.md | 2 +- book/src/data-model/documents.md | 2 +- ...nced_document_requirement_not_met_error.rs | 2 +- .../src/execution/check_tx/v0/mod.rs | 5 +- .../v0/mod.rs | 4 +- .../document_reference_validation/v0/mod.rs | 14 +- .../batch/tests/document/dpns_v3.rs | 134 +++++++++++++++++- .../rs-platform-version/src/version/v14.rs | 14 +- 8 files changed, 152 insertions(+), 25 deletions(-) diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index c469e3565f3..6e13411772e 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -158,7 +158,7 @@ WHERE saltedDomainHash = -- findBy `minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds, and need a function in it. -- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. A commitment recording no creation height counts as old enough: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, all written before the upgrade's block. DPNS preorders made before protocol version 14 are revealed this way. +- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. A commitment recording no creation height meets `minimumAgeBlocks: 1` and no larger value: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, all written in some block before the upgrade's. DPNS preorders made before protocol version 14 are revealed this way. - **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference with the `where` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index f9be6870a25..241e92f5117 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -569,7 +569,7 @@ The document such a key finds is judged once, when the document is created: - A param may be transient (the DPNS salt is), since it is read from the create transition, and optional, since a create missing one is refused. Every stored value `findBy` reads, params and other entries alike, must be fixed once written (the type is immutable or lists the property under `immutable`), and so must a stored property carrying the reference, set when the document is created (not listed under `immutableAllowSetting`). Such a `deletableDocument` found by `findBy` may sit on an `immutable` property, which one re-validated on every replace may not. A replace leaves the reference alone: nothing it reads can have changed, and the commitment may be gone. The declaration's `where` entries are judged with it, on the create alone, so each referring property they name must be fixed once written the same way, or transient. On a document type whose documents can be replaced, such a reference may not be an operand of an `anyOf`: judged on the create alone, it would hold on every replace, whichever operand held on the create. - `creatorRefersTo` takes a `deletableDocument` target only through a function. - `findBy` holds at most one function. The property it fills must be a byte array of exactly the hash's size, 32 bytes for `sys.hash.sha256d`. Beside `findBy`, on the `refersTo`, the reference may then declare what it asks of the document `findBy` finds (both are refused without a function in `findBy`): - - `minimumAgeBlocks`: the found document's `$createdAtBlockHeight` must be at least that many blocks below the height of the create. `1` means an earlier block, so a commitment and its reveal cannot share a block. The referenced type must list `$createdAtBlockHeight` in `required`. A document recording no creation height counts as old enough: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, and the upgrade runs before the first transition of its block, so they are from an earlier block. The DPNS preorders made before protocol version 14 are the ones there are. + - `minimumAgeBlocks`: the found document's `$createdAtBlockHeight` must be at least that many blocks below the height of the create. `1` means an earlier block, so a commitment and its reveal cannot share a block. The referenced type must list `$createdAtBlockHeight` in `required`. A document recording no creation height meets a minimum of `1` and no larger one: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, and the upgrade runs before the first transition of its block, so they are from an earlier block, how much earlier unknown. The DPNS preorders made before protocol version 14 are the ones there are. - `consume: true`: the create deletes the found document in the same state transition, its storage refunded to its owner as a delete by that owner would be. Only on a `deletableDocument` reference whose `where` holds the entry `"$ownerId": "$ownerId"`, into a document type of the declaring contract whose owner may delete its documents (`canBeDeleted: true`), or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, `DocumentTypeV2Getters::documents_deleted_only_when_consumed`). No delete transition is charged for the consumed document, so its type may declare no delete token cost and no delete action fee, and the key signing the create deletes it, so its type may not require a stricter signature security level than the declaring type. - Whose commitment it is, is the `where` entry `"$ownerId": "$ownerId"` beside the function: the writer must own the document found. Without it anyone who learns a preimage may reveal it. diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs index a5907fce99c..7fdf30ef3a7 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs @@ -12,7 +12,7 @@ use thiserror::Error; /// reveals, exists but does not meet what the reference requires of it: `minimumAgeBlocks`, /// its recorded creation block height at least that many blocks below the height of the /// create (a document recording none, written before a protocol upgrade re-stored its system -/// contract, counts as old enough). +/// contract, meets a minimum of 1 and no larger one). #[derive( Error, Debug, diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index fb6d7e4da69..d010daf162c 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1965,8 +1965,9 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed - // contract bytes - 27003119120 + // contract bytes, as does the DPNS v3 preorder type the update copies in (it + // requires `$createdAtBlockHeight`) + 27003143520 ); let check_result = platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 00ec379b784..498419ebc09 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -744,8 +744,8 @@ impl Platform { // deletes the writer's own preorder from an earlier block, and holds its identity // record to its owner, all by schema keywords; the create data trigger keeps only // the parent domain checks. Preorders made before this block record no - // `$createdAtBlockHeight` and stay revealable: a document recording none counts as - // old enough for `minimumAgeBlocks`. + // `$createdAtBlockHeight` and stay revealable: a document recording none meets + // `minimumAgeBlocks: 1`, the preorder reveal's. let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; self.drive.apply_contract( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 5c1e2afd3a8..fb639e3d9a7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -1427,12 +1427,12 @@ fn validate_reference_target_v0( // deletes it when the reference consumes it. Its age is judged in blocks, from its // `$createdAtBlockHeight` (registration demands the type record one) to the // block of the create, so 1 means an earlier block. A document recording none - // counts as old enough: a contract update cannot make a system field required, - // so it was written before a protocol upgrade re-stored a system contract whose - // type now records one (DPNS v3's preorder at protocol version 14), and the - // upgrade runs before the first transition of its block, so the document is from - // an earlier block. Only a lookup the protocol version 14 parser produced has - // either, so no earlier protocol version reaches this + // meets a minimum of 1 and no larger one: a contract update cannot make a system + // field required, so it was written before a protocol upgrade re-stored a system + // contract whose type now records one (DPNS v3's preorder at protocol version 14), + // and the upgrade runs before the first transition of its block, so the document + // is from an earlier block, how much earlier unknown. Only a lookup the protocol + // version 14 parser produced has either, so no earlier protocol version reaches this if let (Some(lookup), Some(found)) = (lookup, referenced_document) { if let Some(blocks) = lookup.minimum_age_blocks { let old_enough = match found.created_at_block_height() { @@ -1440,7 +1440,7 @@ fn validate_reference_target_v0( .height .checked_sub(created_at) .is_some_and(|age| age >= u64::from(blocks)), - None => true, + None => blocks == 1, }; if !old_enough { return Ok(SimpleConsensusValidationResult::new_with_error( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index c50018f3b17..088b11d9bad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -20,11 +20,13 @@ mod dpns_v3_tests { use dpp::consensus::basic::BasicError; use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::state::data_trigger::DataTriggerError; + use dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dpp::data_contract::DataContract; use dpp::data_contracts::SystemDataContract; use dpp::document::Document; use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; + use dpp::platform_value::string_encoding::Encoding; use dpp::prelude::{Identifier, IdentityNonce}; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; @@ -35,6 +37,7 @@ mod dpns_v3_tests { use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; + use drive::util::storage_flags::StorageFlags; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; use simple_signer::signer::SimpleSigner; @@ -188,11 +191,25 @@ mod dpns_v3_tests { values: &[(&str, Value)], absent: &[&str], height: u64, + ) -> (Document, StateTransitionExecutionResult) { + let dpns = self.dpns(); + self.create_in(&dpns, who, type_name, values, absent, height) + .await + } + + /// [`Self::create`] of a document of `contract`. + async fn create_in( + &mut self, + contract: &DataContract, + who: Who, + type_name: &str, + values: &[(&str, Value)], + absent: &[&str], + height: u64, ) -> (Document, StateTransitionExecutionResult) { let platform_version = self.platform_version; let owner_id = self.id(who); - let dpns = self.dpns(); - let document_type = dpns + let document_type = contract .document_type_for_name(type_name) .expect("expected the document type"); let entropy = Bytes32::random_with_rng(&mut self.rng); @@ -470,6 +487,68 @@ mod dpns_v3_tests { .documents_owned() } + /// Registers a contract, owned by Alice, whose `claim` reveals a DPNS preorder + /// (`preorderSalt` refers to it, found by the same hash) at least + /// `minimum_age_blocks` old, and returns it. + fn register_preorder_claims_contract( + fixture: &DpnsFixture, + minimum_age_blocks: u32, + ) -> DataContract { + let contract_json = serde_json::json!({ + "$formatVersion": "1", + "id": Identifier::new([0x5C; 32]).to_string(Encoding::Base58), + "ownerId": fixture.id(Who::Alice).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "claim": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "parent": { "type": "string", "maxLength": 63, "position": 1 }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 2, + "refersTo": { + "type": "deletableDocument", + "contractId": SystemDataContract::DPNS.id().to_string(Encoding::Base58), + "documentType": "preorder", + "findBy": { + "saltedDomainHash": { + "function": "sys.hash.sha256d", + "params": ["preorderSalt", "label", { "const": "." }, "parent"] + } + }, + "minimumAgeBlocks": minimum_age_blocks + } + } + }, + "required": ["label", "parent", "preorderSalt"], + "additionalProperties": false + } + } + }); + let contract = DataContract::from_json(contract_json, true, fixture.platform_version) + .expect("expected to parse the claims contract"); + fixture + .platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + fixture.platform_version, + ) + .expect("expected to apply the claims contract"); + contract + } + /// The commitment a preorder stores, as every client computes it: /// `sha256d(salt ++ normalizedLabel ++ "." ++ parentDomainName)`. fn salted_domain_hash(salt: &[u8; 32], label: &str, parent: &str) -> [u8; 32] { @@ -692,9 +771,10 @@ mod dpns_v3_tests { .await; assert_data_trigger_refusal(&result, "Parent domain is not present"); - // A name under `dash` opening subdomains + // A name under `dash` opening subdomains: the references found and collected the + // preorder for consumption before the trigger refused, and the refusal keeps it let salt: [u8; 32] = fixture.rng.gen(); - fixture + let preorder = fixture .preorder(Who::Alice, salt, "quantum8", "dash", PREORDER_HEIGHT) .await; let (_, result) = fixture @@ -712,6 +792,27 @@ mod dpns_v3_tests { &result, "Allowing subdomains registration is forbidden for this domain", ); + assert!(fixture.stored_preorder(&preorder).is_some()); + assert!(fixture.name("quantum8").is_none()); + + // The same salt, without subdomains, then reveals and consumes it + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum8", + "dash", + &[], + &[], + REVEAL_HEIGHT + 1, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(fixture.stored_preorder(&preorder).is_none()); + assert!(fixture.name("quantum8").is_some()); // A top-level domain by anyone but the DPNS contract owner let salt: [u8; 32] = fixture.rng.gen(); @@ -892,6 +993,31 @@ mod dpns_v3_tests { "a preorder written under v2 decodes unchanged under v3" ); + // Another contract revealing DPNS preorders, three blocks old at least: the upgrade + // shows only that a preorder recording no height is from an earlier block, so + // Alice's, made a block before the upgrade, meets no minimum above 1 + let claims = register_preorder_claims_contract(&fixture, 3); + let (_, result) = fixture + .create_in( + &claims, + Who::Alice, + "claim", + &[ + ("label", Value::Text("quantum8".into())), + ("parent", Value::Text("dash".into())), + ("preorderSalt", salt.into()), + ], + &[], + 21, + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedDocumentRequirementNotMetError(e)) + if e.field() == "minimumAgeBlocks" && e.required() == "3" + ); + + // DPNS asks for one block, which it is let (_, result) = fixture .domain(Who::Alice, salt, "quantum8", "dash", &[], &[], 21) .await; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 62e089bdd25..0c122f05d42 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1616,7 +1616,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// function the reference may declare `minimumAgeBlocks`, judged by /// document create state validation 2 against the found document's /// `$createdAtBlockHeight` (`ReferencedDocumentRequirementNotMetError`, -/// 40142; a found document recording none counts as old enough, 87), and +/// 40142; a found document recording none meets a minimum of 1 only, 87), and /// `consume`, which deletes the found document with the create /// (`DocumentCreateTransitionAction` `consumed_documents`, a batch touching /// it elsewhere refused with 40120). The hash is computed once per key and @@ -2239,12 +2239,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// rule. Data trigger bindings 2, in place, drop the `domain` Replace and /// Delete rejects; `documentsMutable` and `canBeDeleted` false refuse both /// (`InvalidDocumentTransitionActionError`, 10404). The reference -/// validation 0, in place, counts a found document recording no -/// `$createdAtBlockHeight` as old enough for `minimumAgeBlocks`: an update -/// can not make a system field required, so only a system contract a -/// protocol upgrade re-stored holds such documents, all written before the -/// upgrade's block, and the preorders made before this version stay -/// revealable. Inert before this version: only the version 14 parser +/// validation 0, in place, lets a found document recording no +/// `$createdAtBlockHeight` meet `minimumAgeBlocks: 1` and no larger +/// minimum: an update can not make a system field required, so only a +/// system contract a protocol upgrade re-stored holds such documents, all +/// written in some block before the upgrade's, and the preorders made +/// before this version stay revealable. Inert before this version: only the version 14 parser /// produces a `minimumAgeBlocks`, and the earlier tables select DPNS v1 or /// v2, trigger 0 or 1 and bindings 0 or 1. /// From 521761ba6493740f1984ce86c6e0e92a617b61dd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 8 Oct 2026 19:52:58 +0200 Subject: [PATCH 03/62] test(sdk): pin the dpns preorder hash of a mixed-case parent domain name `User.DASH` hashes `user.DASH`, the parent as sent, while the domain carries `parentDomainName: "DASH"` and `normalizedParentDomainName: "dash"`. Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/names/register.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 3175535a856..73c9e9a8195 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -114,6 +114,39 @@ describe('Platform', () => { ]); }); + it('should hash the parent domain name as sent', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + + await register.call(platformMock, 'User.DASH', { + identity: identityId, + }, identityMock); + + // sha256d(salt ++ 'user' ++ '.' ++ 'DASH'): the platform hashes the parent as sent, + // not its normalized form + expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.deep.equal( + '803f9eba5277949b7dcc60c0b2f0e99fcc4e4c26dfb44b2108c6704328a9c4a7', + ); + + expect(platformMock.documents.create.getCall(1).args).to.have.deep.members([ + 'dpns.domain', + identityMock, + { + label: 'User', + normalizedLabel: 'user', + parentDomainName: 'DASH', + normalizedParentDomainName: 'dash', + preorderSalt: Buffer.alloc(32), + records: { + identity: identityId, + }, + subdomainRules: { + allowSubdomains: false, + }, + }, + ]); + }); + it('should fail if DPNS app have no contract set up', async () => { delete platformMock.client.getApps().get('dpns').contractId; From d3518c705ae0bfee6545757e4d508693dd34b735 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 06:12:23 +0200 Subject: [PATCH 04/62] chore(contract): shorten the dpns v3 schema descriptions Short `description`s and no `$comment`s in DPNS v3; the rules they restated are in the schema keywords. The check_tx contract update fee pin follows the smaller preorder type it copies in. Co-Authored-By: Claude Opus 5.5 --- .../schema/v3/dpns-contract-documents.json | 32 ++++++++----------- .../src/execution/check_tx/v0/mod.rs | 6 ++-- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/dpns-contract/schema/v3/dpns-contract-documents.json b/packages/dpns-contract/schema/v3/dpns-contract-documents.json index f250d80e815..2305f4db674 100644 --- a/packages/dpns-contract/schema/v3/dpns-contract-documents.json +++ b/packages/dpns-contract/schema/v3/dpns-contract-documents.json @@ -28,7 +28,7 @@ } ], "resolution": 0, - "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z, A-Z, 0, 1, and -) then a masternode vote contest takes place to give out the name" + "description": "Normalized labels of 3 to 19 a-z, 0, 1 or - characters go to a masternode vote" } }, { @@ -48,7 +48,7 @@ "minLength": 3, "maxLength": 63, "position": 0, - "description": "Domain label. e.g. 'Bob'." + "description": "Label, e.g. 'Bob'" }, "normalizedLabel": { "type": "string", @@ -60,8 +60,7 @@ ] }, "position": 1, - "description": "Domain label converted to lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'b0b'", - "$comment": "Generated by the platform from the label when left out. A value sent must equal what it generates." + "description": "Generated: label lowercased, o to 0, i and l to 1, e.g. 'b0b'" }, "parentDomainName": { "type": "string", @@ -69,7 +68,7 @@ "minLength": 0, "maxLength": 63, "position": 2, - "description": "A full parent domain name. e.g. 'dash'." + "description": "Parent domain name, e.g. 'dash'" }, "normalizedParentDomainName": { "type": "string", @@ -81,8 +80,7 @@ ] }, "position": 3, - "description": "A parent domain name in lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'dash'", - "$comment": "Generated by the platform from parentDomainName when left out. A value sent must equal what it generates. Must name an existing domain, or be empty for a top-level domain, which only the data contract owner can create." + "description": "Generated: parentDomainName normalized like normalizedLabel" }, "preorderSalt": { "type": "array", @@ -90,7 +88,7 @@ "minItems": 32, "maxItems": 32, "position": 4, - "description": "Salt used in the preorder document", + "description": "Salt of the writer's preorder, made in an earlier block; the create deletes it", "refersTo": { "type": "deletableDocument", "documentType": "preorder", @@ -112,8 +110,7 @@ }, "minimumAgeBlocks": 1, "consume": true - }, - "$comment": "Reveals the writer's own preorder, made in an earlier block, whose saltedDomainHash is the double sha-256 of this salt, the normalized label, '.' and the parent domain name. The create deletes the preorder." + } }, "records": { "type": "object", @@ -125,7 +122,7 @@ "maxItems": 32, "position": 1, "contentMediaType": "application/x.dash.dpp.identifier", - "description": "Identifier name record that refers to an Identity" + "description": "Identity the name resolves to; its owner's at creation" } }, "minProperties": 1, @@ -137,13 +134,12 @@ "properties": { "allowSubdomains": { "type": "boolean", - "description": "This option defines who can create subdomains: true - anyone; false - only the domain owner", - "$comment": "Only the domain owner is allowed to create subdomains for non top-level domains", + "description": "Anyone may create subdomains (true) or only the owner (false)", "position": 0 } }, "position": 6, - "description": "Subdomain rules allow domain owners to define rules for subdomains", + "description": "Subdomain rules", "additionalProperties": false, "required": [ "allowSubdomains" @@ -182,8 +178,7 @@ ] } }, - "additionalProperties": false, - "$comment": "In order to register a domain you need to create a preorder in an earlier block. The preorder step is needed to prevent man-in-the-middle attacks. The create deletes the preorder. A new domain's identity record must be its owner (rule recordsIdentityIsOwner, judged until the domain is first transferred or sold). Domain documents are immutable: modification and deletion are refused, transfer and purchase are allowed" + "additionalProperties": false }, "preorder": { "documentsMutable": false, @@ -207,14 +202,13 @@ "minItems": 32, "maxItems": 32, "position": 0, - "description": "Double sha-256 of the concatenation of a 32 byte random salt, the normalized label, '.' and the parent domain name" + "description": "sha256d of salt, normalized label, '.' and parent domain name" } }, "required": [ "$createdAtBlockHeight", "saltedDomainHash" ], - "additionalProperties": false, - "$comment": "Preorder documents are immutable. The domain create that reveals a preorder deletes it" + "additionalProperties": false } } diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index d010daf162c..4480d4394fb 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1965,9 +1965,9 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed - // contract bytes, as does the DPNS v3 preorder type the update copies in (it - // requires `$createdAtBlockHeight`) - 27003143520 + // contract bytes; the DPNS v3 preorder type the update copies in has its own + // (shorter descriptions, `$createdAtBlockHeight` required) + 27003082310 ); let check_result = platform From d93f498893eec4c95b2d7812d8b09b139e1b1433 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 08:00:04 +0200 Subject: [PATCH 05/62] feat(platform)!: dpns v3 preorders are unique per owner and hash the owner A copy of a pending preorder no longer blocks it, and the copier can no longer reveal the name with the copy once the owner's reveal shows the salt: - `preorder` is unique on (`$ownerId`, `saltedDomainHash`), replacing the unique index on the hash alone; - the domain's `preorderSalt` finds it by `$ownerId` and the sha256d of the writer's id, the salt, the normalized label, "." and the parent. The parser (meta-schema v3, parser generation 3) admits `"$ownerId"` as a findBy function param (`LookupKeyParam::Writer`), a `$ownerId` findBy key on a transferable type beside a function, and that key for `consume`. At protocol version 14 every existing preorder is deleted and the type is rebuilt as a fresh v3 chain lays it out (`Drive::apply_contract_rebuilding_document_types`), so the minimumAgeBlocks allowance for preorders recording no creation height is reverted. rs-sdk and js-dash-sdk compute the preorder hash from the contract's own declaration, so either side of the upgrade hashes as the platform does. Co-Authored-By: Claude Opus 5.5 --- .../src/contract-keywords/refers-to-lookup.md | 24 +- book/src/data-model/documents.md | 22 +- .../schema/v3/dpns-contract-documents.json | 12 +- packages/dpns-contract/src/v1/mod.rs | 8 + packages/dpns-contract/src/v3/mod.rs | 13 +- .../Platform/methods/names/register.spec.ts | 40 +++ .../Client/Platform/methods/names/register.ts | 53 ++- .../document/v3/document-meta.json | 6 +- .../class_methods/try_from_schema/mod.rs | 274 +++++++-------- .../v3/commit_reveal_lookup_tests.rs | 41 ++- .../document_type/property/lookup_preimage.rs | 102 ++++-- .../property/reference_lookup.rs | 6 +- ...nced_document_requirement_not_met_error.rs | 3 +- .../src/execution/check_tx/v0/mod.rs | 4 +- .../v0/mod.rs | 16 +- .../document_reference_validation/v0/mod.rs | 36 +- .../batch/state/v0/fetch_documents.rs | 20 +- .../batch/tests/document/creation.rs | 149 +++++---- .../batch/tests/document/dpns.rs | 14 +- .../batch/tests/document/dpns_v3.rs | 314 +++++++++--------- .../tests/document/owner_balance_proof.rs | 13 +- .../state_transition/state_transitions/mod.rs | 37 ++- .../rs-drive-abci/src/test/helpers/dpns.rs | 24 ++ .../rs-drive-abci/src/test/helpers/mod.rs | 3 + .../strategy_tests/test_cases/voting_tests.rs | 59 ++-- ...pply_contract_rebuilding_document_types.rs | 105 ++++++ .../src/drive/contract/migration/mod.rs | 1 + .../system_data_contract_versions/v3.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 63 ++-- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 116 +++++-- .../data_contract/document_type_reference.rs | 1 + 31 files changed, 1028 insertions(+), 557 deletions(-) create mode 100644 packages/rs-drive-abci/src/test/helpers/dpns.rs create mode 100644 packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index 6e13411772e..f7cbc4e2533 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -91,7 +91,7 @@ A `permanentDocument` found by `findBy` never dangles. Its referenced documents - when a property `findBy` reads changed, and then every value, every element included; - when the referring value of a `where` entry changed, or on every replace for a `where` entry whose value is `"$ownerId"`. -A key part read from `"$ownerId"` never triggers a check: only a type whose documents keep their writer may read it. +A key part read from `"$ownerId"` never triggers a check: only a type whose documents keep their writer may read it, or a key with a [function](#commit-and-reveal), judged on the create alone. A `deletableDocument` found by `findBy` promises less. Once the document a key found is deleted, a new document filed later under the same key makes the reference hold again, with different content, where an id reference to a deleted document stays dead. So it means "a document with this key exists now", which is what a membership gate needs. Every replace checks it again, whether or not the replace touched it, and no `immutable` property may hold one. The moderation charters contract's `resignationRequest` uses one to accept a writer who has an `addedModerator` document for the charter at the time of writing, as the alternative to being an elected member (see [Writer and Creator References](owner-refers-to.md)). @@ -128,38 +128,40 @@ One `findBy` entry may hold a function: `"": { "function": "type": "deletableDocument", "documentType": "preorder", "findBy": { + "$ownerId": "$ownerId", "saltedDomainHash": { "function": "sys.hash.sha256d", - "params": ["preorderSalt", "normalizedLabel", { "const": "." }, "parentDomainName"] + "params": ["$ownerId", "preorderSalt", "normalizedLabel", { "const": "." }, "parentDomainName"] } }, - "where": { "$ownerId": "$ownerId" }, "minimumAgeBlocks": 1, "consume": true } } ``` -The salt must reveal a `preorder` whose `saltedDomainHash` is the sha256d of the salt, the normalized label, a dot and the parent. `findBy` names `saltedDomainHash` alone, the whole of the `preorder` type's unique `saltedHash` index. The `where` entry makes it the writer's own preorder, `minimumAgeBlocks: 1` one created in an earlier block, and `consume: true` deletes it with the create. As SQL: +The salt must reveal the writer's own `preorder` whose `saltedDomainHash` is the sha256d of the writer's id, the salt, the normalized label, a dot and the parent. `findBy` names `$ownerId` and `saltedDomainHash`, the whole of the `preorder` type's unique `ownerAndSaltedHash` index, so every identity has its own preorders and a copy of someone else's never collides with theirs. `minimumAgeBlocks: 1` asks for one created in an earlier block, and `consume: true` deletes it with the create. As SQL: ```sql SELECT * FROM preorder -WHERE saltedDomainHash = -- findBy - sha256d(:preorderSalt || :normalizedLabel || '.' || :parentDomainName) - AND $ownerId = :writer -- where +WHERE $ownerId = :writer -- findBy + AND saltedDomainHash = -- findBy + sha256d(:writer || :preorderSalt || :normalizedLabel || '.' || :parentDomainName) AND $createdAtBlockHeight <= :createHeight - 1 -- minimumAgeBlocks -- then the create deletes the row found -- consume ``` -- **Params.** A property path reads the document being created, the property carrying the reference included; `{ "const": text }` is fixed text of 1 to 64 bytes; `"."` is the value carrying the reference where it has no path (each element of a typed array, the writer, the creator); 1 to 16 params. Strings count as their UTF-8, byte arrays as their bytes, identifiers as their 32 bytes. Two values of no fixed length must be separated by a one-byte `const`, and a value holding that byte is refused, so the joined bytes split back one way only. +Hashing the writer matters as much as keying by it. A copy of a pending preorder, stored under the copier's own key, commits to the identity that made it, so the copier, who learns the salt from that identity's reveal, still finds nothing when revealing the same name. + +- **Params.** A property path reads the document being created, the property carrying the reference included; `{ "const": text }` is fixed text of 1 to 64 bytes; `"."` is the value carrying the reference where it has no path (each element of a typed array, the writer, the creator); `"$ownerId"` is the writer's id; 1 to 16 params. Strings count as their UTF-8, byte arrays as their bytes, identifiers as their 32 bytes. Two values of no fixed length must be separated by a one-byte `const`, and a value holding that byte is refused, so the joined bytes split back one way only. - **One function.** `findBy` holds at most one function, and the property it fills must be a byte array of exactly 32 bytes. - **Carrier.** A string or byte array property may carry a `refersTo` only this way, and keeps its type. The value carrying the reference fills the key exactly once, as a `"."` entry or a param; a param names a property by its path, never as `"."`. In `ownerRefersTo` and `creatorRefersTo` the value may be left out beside a function. - **Judged once.** A function's key is checked when the document is created, never on a replace, so every stored value it reads must be fixed once written, the carrier included (listed under `immutable` on a mutable type, and required when it is a `deletableDocument` reference by id, which a replace may otherwise clear once its document is deleted). The `where` entries are judged with it, so each referring property they name must be fixed once written too, or transient. On a type whose documents can be replaced it may not be an operand of an `anyOf`, which would then hold on every replace. Params may be transient: they are read from the create. The hash is billed as the double SHA-256 it is, by the blocks it hashes, beside the document fetch. `minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds, and need a function in it. -- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. A commitment recording no creation height meets `minimumAgeBlocks: 1` and no larger value: a contract update can not make a system field required, so only a system contract a protocol upgrade re-stored holds such documents, all written in some block before the upgrade's. DPNS preorders made before protocol version 14 are revealed this way. -- **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference with the `where` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). +- **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. +- **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference that makes the writer the commitment's owner, by the `where` entry `"$ownerId": "$ownerId"` or the `findBy` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. @@ -171,7 +173,7 @@ A create missing a param, or with a value holding its separator, is refused befo - The reference's own value is read exactly once, as a `"."` entry or as a param of a [function](#commit-and-reveal). Without it every value would find the same document. Only `ownerRefersTo` or `creatorRefersTo` may leave it out, beside a function. - Every other source is `"$ownerId"`, a property path or a function. No other `$` name is accepted, and a path may not name the reference property itself: write `"."` for that. - A property an entry reads must exist on the referring type, be required (and so must every object around it), not be `transient` or inside a transient object, and hold a single value, not an object or an array. `findBy` never runs with a missing key part, and a reader can assemble the same key from the stored document. A function's params follow [their own rules](#commit-and-reveal): they may be transient or optional, since they are read from the create alone. -- A `"$ownerId"` source needs a referring type whose documents can be neither transferred nor traded: a transfer or a purchase would move the writer part of the key without a write. +- A `"$ownerId"` source needs a referring type whose documents can be neither transferred nor traded, unless the `findBy` holds a [function](#commit-and-reveal): a transfer or a purchase would move the writer part of the key without a write, but a key with a function is judged on the create alone. - A `deletableDocument` found by `findBy` may not sit under an `immutable` property, alone or as an operand of an expression: every replace checks it again, so once its document is deleted the property would have to change. One whose key a [function](#commit-and-reveal) computes is the exception: it is checked on the create alone, and its carrier must be fixed once written. - `{ "$id": ... }` is allowed only with [`inList`](refers-to-list-element.md), as the only entry. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 7fb29202772..6e67d0eb32d 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -510,7 +510,7 @@ A document type whose documents can be transferred or traded declares `creatorRe ### Commit and reveal (a `findBy` function) -One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind: a `preorder` holds `saltedDomainHash` under a unique index. Before protocol version 14 the `domain` create trigger hashed what the domain reveals and looked for it; from 14, DPNS contract v3 declares it on the salt the domain reveals: +One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind: a `preorder` holds `saltedDomainHash` under a unique index. Before protocol version 14 the `domain` create trigger hashed what the domain reveals and looked for it; from 14, DPNS contract v3 declares it on the salt the domain reveals, its preorders unique per owner: ```json "preorder": { @@ -523,7 +523,11 @@ One `findBy` entry may hold a function instead of a source: `" Result { diff --git a/packages/dpns-contract/src/v3/mod.rs b/packages/dpns-contract/src/v3/mod.rs index 80c1319b4d8..150eb4472bd 100644 --- a/packages/dpns-contract/src/v3/mod.rs +++ b/packages/dpns-contract/src/v3/mod.rs @@ -2,16 +2,19 @@ use crate::Error; use serde_json::Value; /// The document type names and property names are unchanged from v1 (see -/// [`crate::v1::document_types`]), and so are every property's position and the -/// transient list, so documents written under v1 or v2 decode unchanged. v3 replaces +/// [`crate::v1::document_types`]), and so are every `domain` property's position and +/// the transient list, so domains written under v1 or v2 decode unchanged. v3 replaces /// most of the `domain` create data trigger with schema keywords: `normalizedLabel` /// and `normalizedParentDomainName` are generated from their counterparts /// (`generatedFrom`), `preorderSalt` reveals the writer's own preorder from an -/// earlier block and deletes it (`refersTo` with `findBy`, `where`, +/// earlier block and deletes it (`refersTo` with a `findBy` over the writer and the +/// hash of the writer's id, the salt, the normalized label, "." and the parent, /// `minimumAgeBlocks` and `consume`, which needs the preorder to record /// `$createdAtBlockHeight`), and the `recordsIdentityIsOwner` rule holds a new -/// domain's identity record to its owner. Domains can no longer be deleted -/// (`canBeDeleted: false`). The parent domain checks stay in the create trigger. +/// domain's identity record to its owner. Preorders are unique per owner +/// (`ownerAndSaltedHash`), so the protocol upgrade deletes the v2 preorders and +/// rebuilds the type. Domains can no longer be deleted (`canBeDeleted: false`). The +/// parent domain checks stay in the create trigger. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v3/dpns-contract-documents.json")) .map_err(Error::InvalidSchemaJson) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 73c9e9a8195..d1bb0c79e8e 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -39,6 +39,10 @@ describe('Platform', () => { create: this.sinon.stub(), broadcast: this.sinon.stub(), }, + // A DPNS contract declaring no preorder hash: the hash of the salt and the name + contracts: { + get: this.sinon.stub().resolves(null), + }, initialize: this.sinon.stub(), }; @@ -147,6 +151,42 @@ describe('Platform', () => { ]); }); + it('should hash the params the DPNS contract declares', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves({ + getDocumentSchema: () => ({ + properties: { + preorderSalt: { + refersTo: { + findBy: { + saltedDomainHash: { + function: 'sys.hash.sha256d', + params: ['$ownerId', 'preorderSalt', 'normalizedLabel', { const: '.' }, 'parentDomainName'], + }, + }, + }, + }, + }, + }), + }); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + // sha256d(owner id ++ salt ++ 'user' ++ '.' ++ 'dash') + const sha256 = (data) => cryptoModule.createHash('sha256').update(data).digest(); + const expected = sha256(sha256(Buffer.concat([ + identityId.toBuffer(), + Buffer.alloc(32), + Buffer.from('user.dash'), + ]))); + expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.equal( + expected.toString('hex'), + ); + }); + it('should fail if DPNS app have no contract set up', async () => { delete platformMock.client.getApps().get('dpns').contractId; diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 7196ad42ab4..39105243944 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -48,21 +48,52 @@ export async function register( const isSecondLevelDomain = normalizedParentDomainName.length > 0; - // The preorder commits to the normalized label, a dot and the parent domain - // name as sent: the hash the platform checks the domain's preorderSalt against - const fullDomainName = `${normalizedLabel}.${parentDomainName}`; - - const saltedDomainHash = hash( - Buffer.concat([ - preorderSalt, - Buffer.from(fullDomainName), - ]), - ); - if (!this.client.getApps().has('dpns')) { throw new Error('DPNS is required to register a new name.'); } + // The preorder commits to the hash the platform checks the domain's + // preorderSalt against. From DPNS v3 the contract declares it: the params of + // the salt's findBy function (the writer's id, the salt, the normalized label, + // '.' and the parent domain name as sent). Before it, the salt and + // `${normalizedLabel}.${parentDomainName}`. + const { contractId } = this.client.getApps().get('dpns'); + const dpnsContract = await this.contracts.get(contractId); + const domainSchema = dpnsContract ? dpnsContract.getDocumentSchema('domain') : undefined; + const hashParams = domainSchema?.properties?.preorderSalt?.refersTo + ?.findBy?.saltedDomainHash?.params; + + const paramBytes = (param: any): Buffer => { + if (typeof param === 'object' && param !== null) { + return Buffer.from(param.const); + } + switch (param) { + case '$ownerId': + return Buffer.from(identity.getId().toBuffer()); + case 'preorderSalt': + return preorderSalt; + case 'label': + return Buffer.from(label); + case 'normalizedLabel': + return Buffer.from(normalizedLabel); + case 'parentDomainName': + return Buffer.from(parentDomainName); + case 'normalizedParentDomainName': + return Buffer.from(normalizedParentDomainName); + default: + throw new Error(`Unknown DPNS preorder hash param ${param}`); + } + }; + + const saltedDomainHash = hash( + Array.isArray(hashParams) + ? Buffer.concat(hashParams.map(paramBytes)) + : Buffer.concat([ + preorderSalt, + Buffer.from(`${normalizedLabel}.${parentDomainName}`), + ]), + ); + // 1. Create preorder document const preorderDocument = await this.documents.create( 'dpns.preorder', 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 51e13ab59ca..f1d1a819080 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 @@ -60,7 +60,7 @@ } }, "findByFunction": { - "description": "The function of a findBy entry, the same function and params shape as generatedFrom: a sys.hash function (sys.hash.sha256d, SHA-256 of SHA-256, 32 bytes) of the params' bytes joined in order with nothing between them, the value of the referenced property the entry names that finds the document. A param is the dotted path of a string (its UTF-8), byte array (its bytes) or identifier (its 32 bytes) property of the referring document type, the property carrying the reference included; { \"const\": text }, fixed UTF-8 text of 1 to 64 bytes; or \".\", the value carrying the reference where it has no path (each element of a typed array, the writer of an ownerRefersTo, the creator of a creatorRefersTo). See findBy for what the computed key means.", + "description": "The function of a findBy entry, the same function and params shape as generatedFrom: a sys.hash function (sys.hash.sha256d, SHA-256 of SHA-256, 32 bytes) of the params' bytes joined in order with nothing between them, the value of the referenced property the entry names that finds the document. A param is the dotted path of a string (its UTF-8), byte array (its bytes) or identifier (its 32 bytes) property of the referring document type, the property carrying the reference included; { \"const\": text }, fixed UTF-8 text of 1 to 64 bytes; \".\", the value carrying the reference where it has no path (each element of a typed array, the writer of an ownerRefersTo, the creator of a creatorRefersTo); or \"$ownerId\", the writer's 32-byte id, which binds the commitment to the identity that may reveal it. See findBy for what the computed key means.", "type": "object", "properties": { "function": { @@ -76,6 +76,10 @@ "type": "string", "const": "." }, + { + "type": "string", + "const": "$ownerId" + }, { "type": "string", "minLength": 1, 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 3be4a02f06b..28f744e7df4 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 @@ -1241,167 +1241,169 @@ fn parse_reference_target( refers_to_map: &BTreeMap, reference_type: &str, ) -> Result { - let target = match reference_type { - "identity" => DocumentPropertyReferenceTarget::Identity, - "contract" => DocumentPropertyReferenceTarget::Contract { - contract_requirements: parse_contract_reference_requirements(refers_to_map)?, - }, - "token" => DocumentPropertyReferenceTarget::Token, - // A moderated document is found by the value as its id alone (the keys - // refused `findBy` and `inList`), with the declaration shape the other - // two share; whether the referenced document type leaves its documents - // only to a moderator's recorded removal is checked against state at - // contract registration - // - // A writer gate (a `where` entry valued `"$ownerId"`) is asked about on - // every replace, and once a moderator removes the document only its owner - // and its id are sure to be in its removal record (the fields a type keeps, - // `moderatorAbilities.deleteKeepsFields`, are left out where the document - // held none, and the type may be another contract's): a gate on any other - // property could refuse every later replace, for good on a property that - // can not be repointed or cleared, so it may compare only those two - "moderatedDocument" => { - let (contract_id, document_type_name) = - parse_document_reference_type(refers_to_map, reference_type)?; - let property_agreement = parse_where(refers_to_map, reference_type)?; - if let Some(referenced_property) = - property_agreement - .iter() - .find_map(|(referring_property, referenced_property)| { + let target = + match reference_type { + "identity" => DocumentPropertyReferenceTarget::Identity, + "contract" => DocumentPropertyReferenceTarget::Contract { + contract_requirements: parse_contract_reference_requirements(refers_to_map)?, + }, + "token" => DocumentPropertyReferenceTarget::Token, + // A moderated document is found by the value as its id alone (the keys + // refused `findBy` and `inList`), with the declaration shape the other + // two share; whether the referenced document type leaves its documents + // only to a moderator's recorded removal is checked against state at + // contract registration + // + // A writer gate (a `where` entry valued `"$ownerId"`) is asked about on + // every replace, and once a moderator removes the document only its owner + // and its id are sure to be in its removal record (the fields a type keeps, + // `moderatorAbilities.deleteKeepsFields`, are left out where the document + // held none, and the type may be another contract's): a gate on any other + // property could refuse every later replace, for good on a property that + // can not be repointed or cleared, so it may compare only those two + "moderatedDocument" => { + let (contract_id, document_type_name) = + parse_document_reference_type(refers_to_map, reference_type)?; + let property_agreement = parse_where(refers_to_map, reference_type)?; + if let Some(referenced_property) = property_agreement.iter().find_map( + |(referring_property, referenced_property)| { (is_referring_system_agreement_property(referring_property) && !matches!(referenced_property.as_str(), OWNER_ID | ID)) .then_some(referenced_property) - }) - { - return Err(DataContractError::InvalidContractStructure(format!( - "moderatedDocument refersTo where compares the writer with \ + }, + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "moderatedDocument refersTo where compares the writer with \ \"{referenced_property}\": a writer is checked on every replace, and \ once a moderator removes the document only its $ownerId and $id are sure \ to be in its removal record, so the writer may be compared with those \ alone" - ))); - } - DocumentPropertyReferenceTarget::ModeratedDocument { - contract_id, - document_type_name, - property_agreement, + ))); + } + DocumentPropertyReferenceTarget::ModeratedDocument { + contract_id, + document_type_name, + property_agreement, + } } - } - // The two document targets share one declaration shape; they differ in - // whether the referenced document type must forbid deletion, checked - // against state at contract registration. The document is found by the - // value as its id, by `findBy` through a unique index, or, for a list - // the value must be in (`inList`, permanent only), by a `findBy` naming - // its `$id` from another property - document_target @ ("permanentDocument" | "deletableDocument") => { - let (contract_id, document_type_name) = - parse_document_reference_type(refers_to_map, document_target)?; - - let property_agreement = parse_where(refers_to_map, document_target)?; - let find_by = refers_to_map.get(property_names::FIND_BY); - - if refers_to_map.contains_key(property_names::IN_LIST) { - let Some(find_by) = find_by else { - return Err(DataContractError::InvalidContractStructure( + // The two document targets share one declaration shape; they differ in + // whether the referenced document type must forbid deletion, checked + // against state at contract registration. The document is found by the + // value as its id, by `findBy` through a unique index, or, for a list + // the value must be in (`inList`, permanent only), by a `findBy` naming + // its `$id` from another property + document_target @ ("permanentDocument" | "deletableDocument") => { + let (contract_id, document_type_name) = + parse_document_reference_type(refers_to_map, document_target)?; + + let property_agreement = parse_where(refers_to_map, document_target)?; + let find_by = refers_to_map.get(property_names::FIND_BY); + + if refers_to_map.contains_key(property_names::IN_LIST) { + let Some(find_by) = find_by else { + return Err(DataContractError::InvalidContractStructure( "permanentDocument refersTo inList needs findBy { \"$id\": }: the value is an element of the list, not \ the id of the document holding it" .to_string(), )); - }; - return Ok(DocumentPropertyReferenceTarget::ListElement( - parse_list_element_reference( - refers_to_map, - find_by, - contract_id, - document_type_name, - property_agreement, - )?, - )); - } + }; + return Ok(DocumentPropertyReferenceTarget::ListElement( + parse_list_element_reference( + refers_to_map, + find_by, + contract_id, + document_type_name, + property_agreement, + )?, + )); + } - // A lookup is its own variant, so an id reference keeps its shape - // (and its encoding in the reference errors) - let Some(find_by) = find_by else { - return Ok(match document_target { - "permanentDocument" => DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - property_agreement, - }, - _ => DocumentPropertyReferenceTarget::DeletableDocument { - contract_id, - document_type_name, - property_agreement, - }, - }); - }; - let lookup = parse_find_by(refers_to_map, find_by)?; - match document_target { - "permanentDocument" => { - // A permanent document is never deleted, so there is - // nothing to consume - if lookup.consume { - return Err(DataContractError::InvalidContractStructure( - "permanentDocument refersTo cannot consume the document findBy \ + // A lookup is its own variant, so an id reference keeps its shape + // (and its encoding in the reference errors) + let Some(find_by) = find_by else { + return Ok(match document_target { + "permanentDocument" => DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + property_agreement, + }, + _ => DocumentPropertyReferenceTarget::DeletableDocument { + contract_id, + document_type_name, + property_agreement, + }, + }); + }; + let lookup = parse_find_by(refers_to_map, find_by)?; + match document_target { + "permanentDocument" => { + // A permanent document is never deleted, so there is + // nothing to consume + if lookup.consume { + return Err(DataContractError::InvalidContractStructure( + "permanentDocument refersTo cannot consume the document findBy \ finds, which is never deleted: consume needs a deletableDocument \ reference" - .to_string(), - )); - } - DocumentPropertyReferenceTarget::PermanentDocumentLookup { - contract_id, - document_type_name, - property_agreement, - lookup, + .to_string(), + )); + } + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } } - } - _ => { - // Consuming deletes the found document, so only its - // owner's own reveal may: the `$ownerId` entry makes the - // writer the found document's owner - if lookup.consume - && property_agreement.get(OWNER_ID).map(String::as_str) != Some(OWNER_ID) - { - return Err(DataContractError::InvalidContractStructure( - "deletableDocument refersTo may consume the document findBy finds \ + _ => { + // Consuming deletes the found document, so only its + // owner's own reveal may: a `$ownerId` entry of `where`, or of + // `findBy` keying the found document's owner by the writer, + // makes the writer the found document's owner + let owner_is_writer = property_agreement.get(OWNER_ID).map(String::as_str) + == Some(OWNER_ID) + || matches!(lookup.keys.get(OWNER_ID), Some(LookupKeySource::OwnerId)); + if lookup.consume && !owner_is_writer { + return Err(DataContractError::InvalidContractStructure( + "deletableDocument refersTo may consume the document findBy finds \ only when the writer owns it: declare the where entry \ + \"$ownerId\": \"$ownerId\", or the findBy entry \ \"$ownerId\": \"$ownerId\"" - .to_string(), - )); - } - DocumentPropertyReferenceTarget::DeletableDocumentLookup { - contract_id, - document_type_name, - property_agreement, - lookup, + .to_string(), + )); + } + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } } } } - } - "identityPublicKey" => { - let key_id_property = refers_to_map - .get_str(property_names::KEY_ID_PROPERTY) - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - - if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { - return Err(DataContractError::InvalidContractStructure(format!( - "identityPublicKey refersTo keyIdProperty must be between 1 and \ + "identityPublicKey" => { + let key_id_property = refers_to_map + .get_str(property_names::KEY_ID_PROPERTY) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + + if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyIdProperty must be between 1 and \ {MAX_PROPERTY_PATH_LENGTH} characters" - ))); - } + ))); + } - DocumentPropertyReferenceTarget::IdentityPublicKey { - key_id_property: key_id_property.to_string(), - key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: key_id_property.to_string(), + key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, + } } - } - other => { - return Err(DataContractError::InvalidContractStructure(format!( - "invalid refersTo type {other}" - ))) - } - }; + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "invalid refersTo type {other}" + ))) + } + }; Ok(target) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs index 54976b3ecc5..83ff83cb2aa 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs @@ -268,6 +268,43 @@ fn should_parse_the_dpns_salt_as_revealing_the_preorder_with_its_age_consumption assert!(salt.revealed_reference.is_some()); } +/// DPNS v3's reveal: preorders are unique per owner, and `findBy` keys the +/// preorder's `$ownerId` by the writer beside the function. The key is judged on +/// the create alone, so the transferable domain may read the writer there, and it +/// makes the writer the preorder's owner, which `consume` asks, without a `where`. +#[test] +fn should_parse_an_owner_keyed_reveal_on_a_transferable_type_and_let_it_consume() { + let preorder_per_owner = json!({ + "indices": [{ + "name": "ownerAndSaltedHash", + "properties": [{ "$ownerId": "asc" }, { "saltedDomainHash": "asc" }], + "unique": true + }] + }); + let reveal = reveal_with( + dpns_function(), + json!({ "$ownerId": "$ownerId" }), + json!({}), + json!({ "minimumAgeBlocks": 1, "consume": true }), + ); + let parsed = contract(dpns_contract_with(reveal, json!({}), preorder_per_owner)) + .expect("an owner-keyed reveal should parse on a transferable type"); + + let mut lookup = dpns_lookup(Some(1), true); + lookup + .keys + .insert("$ownerId".to_string(), LookupKeySource::OwnerId); + assert_eq!( + salt_reference(&parsed), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id: None, + document_type_name: "preorder".to_string(), + property_agreement: BTreeMap::new(), + lookup, + } + ); +} + #[test] fn should_parse_a_function_pair_that_demands_nothing_more() { // No owner entry, no minimum age, no consume: each is optional, and @@ -387,8 +424,8 @@ fn should_refuse_malformed_functions_in_the_parser() { "const holds between 1 and 64 bytes", ), ( - json!({ "function": "sys.hash.sha256d", "params": ["preorderSalt", "$ownerId"] }), - "not \"$ownerId\"", + json!({ "function": "sys.hash.sha256d", "params": ["preorderSalt", "$creatorId"] }), + "not \"$creatorId\"", ), ( json!({ "function": "sys.hash.sha256d", "params": ["preorderSalt", { "value": "." }] }), diff --git a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs index 1aad6366780..236355a606e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs @@ -32,9 +32,10 @@ //! between them: a dotted path a property of the referring document (a //! string's UTF-8, a byte array's bytes, an identifier's 32 bytes), the //! property carrying the reference included, named by its path; -//! `{ "const": text }` fixed UTF-8 text; and `"."` the value carrying the +//! `{ "const": text }` fixed UTF-8 text; `"."` the value carrying the //! reference where it has no path (each element of a typed array, the writer -//! or the creator). So that the joined bytes split back into the params one +//! or the creator); and `"$ownerId"` the writer's 32-byte id, which binds the +//! commitment to whoever may reveal it. So that the joined bytes split back into the params one //! way only, every variable-length param (a string, or a byte array whose size //! is not fixed) that another variable-length param follows is followed //! directly by a one-byte `const`, its separator, and a value holding that @@ -67,6 +68,8 @@ pub const LOOKUP_KEY_PARAMS: &str = "params"; pub const LOOKUP_KEY_CONST: &str = "const"; /// The param naming the value carrying the reference. pub const LOOKUP_KEY_REFERENCE_VALUE: &str = "."; +/// The param naming the writer, the document's owner when it is created. +pub const LOOKUP_KEY_WRITER: &str = "$ownerId"; /// The most params a computed key may list, the bound meta-schema v3 puts on /// it. @@ -93,6 +96,9 @@ pub enum LookupKeyParam { Property(String), /// `{ "const": text }`: fixed text, as UTF-8. Const(String), + /// `"$ownerId"`: the writer's id, its 32 bytes. A commitment hashing it can be + /// revealed only by the identity it was made for, whoever stores a copy of it. + Writer, } impl Serialize for LookupKeyParam { @@ -105,6 +111,7 @@ impl Serialize for LookupKeyParam { map.serialize_entry(LOOKUP_KEY_CONST, text)?; map.end() } + LookupKeyParam::Writer => serializer.serialize_str(LOOKUP_KEY_WRITER), } } } @@ -288,7 +295,8 @@ impl LookupHashKey { /// order, borrowed from the document where they can be: `"."` reads /// `reference_id`, the identifier carrying the reference where it has no /// path (an element's, the writer's or the creator's id; a property carrying - /// the reference is named by its path, so it passes `None`). An error names + /// the reference is named by its path, so it passes `None`), and `"$ownerId"` + /// reads `writer_id`, the identity creating the document. An error names /// the first param whose value is absent, of a kind a param cannot take, /// behind a repeated key, or a variable-length value holding the separator /// that follows it. @@ -296,6 +304,7 @@ impl LookupHashKey { &'a self, declaring: DocumentTypeRef, reference_id: Option, + writer_id: Identifier, document_data: &'a BTreeMap, ) -> Result>, LookupPreimageError> { let mut parts = Vec::with_capacity(self.params.len()); @@ -312,6 +321,7 @@ impl LookupHashKey { })?; Cow::Owned(reference_id.to_buffer().to_vec()) } + LookupKeyParam::Writer => Cow::Owned(writer_id.to_buffer().to_vec()), LookupKeyParam::Property(path) => { value_bytes(path, read_param(document_data, path)?)? } @@ -348,9 +358,10 @@ impl LookupHashKey { &self, declaring: DocumentTypeRef, reference_id: Option, + writer_id: Identifier, document_data: &BTreeMap, ) -> Result<(), LookupPreimageError> { - self.parts(declaring, reference_id, document_data) + self.parts(declaring, reference_id, writer_id, document_data) .map(|_| ()) } @@ -360,9 +371,12 @@ impl LookupHashKey { &self, declaring: DocumentTypeRef, reference_id: Option, + writer_id: Identifier, document_data: &BTreeMap, ) -> Result, LookupPreimageError> { - Ok(self.parts(declaring, reference_id, document_data)?.concat()) + Ok(self + .parts(declaring, reference_id, writer_id, document_data)? + .concat()) } /// The value of the index property this key fills for a create of a @@ -373,9 +387,10 @@ impl LookupHashKey { &self, declaring: DocumentTypeRef, reference_id: Option, + writer_id: Identifier, document_data: &BTreeMap, ) -> Result<([u8; 32], u16), LookupPreimageError> { - let preimage = self.preimage(declaring, reference_id, document_data)?; + let preimage = self.preimage(declaring, reference_id, writer_id, document_data)?; Ok(( self.function.digest(&preimage), self.function.block_count(preimage.len()), @@ -383,17 +398,21 @@ impl LookupHashKey { } } -/// Reads one param: `"."`, a dotted property path, or `{ "const": text }`. +/// Reads one param: `"."`, `"$ownerId"`, a dotted property path, or +/// `{ "const": text }`. fn parse_param(value: &Value) -> Result { if let Some(path) = value.as_text() { if path == LOOKUP_KEY_REFERENCE_VALUE { return Ok(LookupKeyParam::ReferenceValue); } + if path == LOOKUP_KEY_WRITER { + return Ok(LookupKeyParam::Writer); + } if path.is_empty() || path.len() > MAX_LOOKUP_KEY_PATH_LENGTH || path.starts_with('$') { return Err(DataContractError::InvalidContractStructure(format!( - "a refersTo findBy function param is \".\", {{ \"const\": text }} or the path \ - of a schema property of 1 to {MAX_LOOKUP_KEY_PATH_LENGTH} characters, not \ - {path:?}" + "a refersTo findBy function param is \".\", \"$ownerId\", {{ \"const\": text }} \ + or the path of a schema property of 1 to {MAX_LOOKUP_KEY_PATH_LENGTH} \ + characters, not {path:?}" ))); } return Ok(LookupKeyParam::Property(path.to_string())); @@ -435,17 +454,20 @@ fn param_name(param: &LookupKeyParam) -> &str { LookupKeyParam::ReferenceValue => LOOKUP_KEY_REFERENCE_VALUE, LookupKeyParam::Property(path) => path, LookupKeyParam::Const(text) => text, + LookupKeyParam::Writer => LOOKUP_KEY_WRITER, } } /// Whether `param` has no fixed length: a string, or a byte array whose /// minimum and maximum sizes differ, read from a property of `declaring`. A -/// `const` is fixed, and so is `"."`, always an identifier (an element's, the -/// writer's or the creator's id); a path naming no property is judged by -/// [`LookupHashKey::referring_side_error`]. +/// `const` is fixed, and so are `"."`, always an identifier (an element's, the +/// writer's or the creator's id), and `"$ownerId"`, the writer's; a path naming +/// no property is judged by [`LookupHashKey::referring_side_error`]. fn is_variable_length(param: &LookupKeyParam, declaring: DocumentTypeRef) -> bool { let property_type = match param { - LookupKeyParam::Const(_) | LookupKeyParam::ReferenceValue => return false, + LookupKeyParam::Const(_) | LookupKeyParam::ReferenceValue | LookupKeyParam::Writer => { + return false + } LookupKeyParam::Property(path) => match declaring.flattened_properties().get(path) { Some(property) => &property.property_type, None => return false, @@ -575,7 +597,8 @@ pub fn first_unrevealable_lookup_key( // A property carrying the reference is named by its path (ReferenceHolder::Property(_), _) => None, }; - if let Err(error) = key.check_preimage(document_type, reference_id, document_data) { + if let Err(error) = key.check_preimage(document_type, reference_id, owner_id, document_data) + { return Some((holder.path().to_string(), error)); } } @@ -678,7 +701,7 @@ mod tests { ("preorderSalt", salt_value.clone()), ]); assert_eq!( - key.key_value(domain.as_ref(), None, &document) + key.key_value(domain.as_ref(), None, Identifier::default(), &document) .map(|(digest, _)| Value::Bytes32(digest)), Ok(trigger_hash(salt, normalized_label, parent)), "{normalized_label} {parent}" @@ -687,6 +710,41 @@ mod tests { } } + /// `"$ownerId"` reads the writer's 32 bytes, so the same preimage values hash + /// to a different key for every writer: DPNS v3's preorder hash. + #[test] + fn should_hash_the_writer_for_an_owner_id_param() { + let domain = domain(); + let key = LookupHashKey::from_value(&platform_value!({ + "function": "sys.hash.sha256d", + "params": ["$ownerId", "preorderSalt", "normalizedLabel", { "const": "." }, "parentDomainName"] + })) + .expect("the key should parse"); + assert_eq!(key.params[0], LookupKeyParam::Writer); + let document = data(&[ + ("normalizedLabel", "al1ce".into()), + ("parentDomainName", "dash".into()), + ("preorderSalt", Value::Bytes32([0x42; 32])), + ]); + let alice = Identifier::new([0xA1; 32]); + let mut expected = alice.to_buffer().to_vec(); + expected.extend([0x42; 32]); + expected.extend(b"al1ce.dash"); + assert_eq!( + key.preimage(domain.as_ref(), None, alice, &document), + Ok(expected) + ); + assert_ne!( + key.key_value(domain.as_ref(), None, alice, &document), + key.key_value( + domain.as_ref(), + None, + Identifier::new([0xB0; 32]), + &document + ) + ); + } + #[test] fn should_report_the_blocks_a_key_hashes_and_take_the_hash_the_caller_billed() { let domain = domain(); @@ -699,7 +757,7 @@ mod tests { // 32 + 5 + 1 + 4 bytes pad to one block, plus the second pass assert_eq!( dpns_key() - .key_value(domain.as_ref(), None, &document) + .key_value(domain.as_ref(), None, Identifier::default(), &document) .map(|(digest, blocks)| (Value::Bytes32(digest), blocks)), Ok((hash.clone(), 2)) ); @@ -823,7 +881,7 @@ mod tests { ("preorderSalt", salt.clone()), ]); assert!(matches!( - key.preimage(domain.as_ref(), None, &missing_parent), + key.preimage(domain.as_ref(), None, Identifier::default(), &missing_parent), Err(LookupPreimageError { param, .. }) if param == "parentDomainName" )); @@ -834,7 +892,7 @@ mod tests { ("preorderSalt", salt.clone()), ]); assert!(matches!( - key.preimage(domain.as_ref(), None, &dotted_label), + key.preimage(domain.as_ref(), None, Identifier::default(), &dotted_label), Err(LookupPreimageError { param, .. }) if param == "normalizedLabel" )); @@ -844,7 +902,9 @@ mod tests { ("parentDomainName", "b.c".into()), ("preorderSalt", salt.clone()), ]); - assert!(key.preimage(domain.as_ref(), None, &dotted_parent).is_ok()); + assert!(key + .preimage(domain.as_ref(), None, Identifier::default(), &dotted_parent) + .is_ok()); } #[test] @@ -895,7 +955,7 @@ mod tests { platform_value!({ "function": "sys.hash.sha256d" }), platform_value!({ "function": "sys.hash.sha256d", "params": ["."], "extra": 1 }), platform_value!({ "function": "sys.hash.sha256d", "params": [{ "const": "" }] }), - platform_value!({ "function": "sys.hash.sha256d", "params": ["$ownerId"] }), + platform_value!({ "function": "sys.hash.sha256d", "params": ["$creatorId"] }), platform_value!({ "function": "sys.hash.sha256d", "params": [{ "text": "." }] }), platform_value!({ "sha256d": ["."] }), ] { diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index 0bd8b8bc0c1..aaef5c93c30 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -431,12 +431,14 @@ impl DocumentReferenceLookup { continue; } LookupKeySource::OwnerId => { - if owner_can_change(declaring) { + // A lookup with a computed key is judged when the document is created + // only, so a later transfer or purchase moving the writer moves no key + if !computed && owner_can_change(declaring) { return Some(format!( "findBy \"{index_property}\" reads \"$ownerId\", which a transfer or \ a purchase of the referring document changes without re-validating \ the reference: findBy may read the writer only on a document type \ - that cannot be transferred or traded" + that cannot be transferred or traded, or beside a function" )); } continue; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs index 7fdf30ef3a7..89b2ec5b3f8 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_requirement_not_met_error.rs @@ -11,8 +11,7 @@ use thiserror::Error; /// The document a `refersTo` lookup with a computed key found, the commitment a create /// reveals, exists but does not meet what the reference requires of it: `minimumAgeBlocks`, /// its recorded creation block height at least that many blocks below the height of the -/// create (a document recording none, written before a protocol upgrade re-stored its system -/// contract, meets a minimum of 1 and no larger one). +/// create (a document recording none never meets it). #[derive( Error, Debug, diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 4480d4394fb..10ecc0448f9 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1966,8 +1966,8 @@ mod tests { // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed // contract bytes; the DPNS v3 preorder type the update copies in has its own - // (shorter descriptions, `$createdAtBlockHeight` required) - 27003082310 + // (its per-owner index, `$createdAtBlockHeight` required) + 27003089990 ); let check_result = platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 498419ebc09..b85218faf36 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -9,6 +9,7 @@ use dpp::data_contracts::SystemDataContract; use dpp::fee::Credits; use dpp::platform_value::Identifier; use dpp::serialization::PlatformDeserializableTrusted; +use dpp::system_data_contracts::dpns_contract::v1::document_types::preorder; use dpp::system_data_contracts::load_system_data_contract; use dpp::version::PlatformVersion; use dpp::version::ProtocolVersion; @@ -743,17 +744,18 @@ impl Platform { // DPNS contract v3: a domain create generates its normalized names, reveals and // deletes the writer's own preorder from an earlier block, and holds its identity // record to its owner, all by schema keywords; the create data trigger keeps only - // the parent domain checks. Preorders made before this block record no - // `$createdAtBlockHeight` and stay revealable: a document recording none meets - // `minimumAgeBlocks: 1`, the preorder reveal's. + // the parent domain checks. Its preorders are unique per owner, which no contract + // update can turn a unique index into, so the `preorder` type is rebuilt: every + // preorder made before this block is deleted, unrefunded, and the type laid out as + // a fresh chain registering DPNS v3 lays it out. let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; - self.drive.apply_contract( + self.drive.apply_contract_rebuilding_document_types( &dpns_contract, - *block_info, - true, + &[preorder::NAME], + block_info, None, - Some(transaction), + transaction, platform_version, )?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index fb639e3d9a7..c76a588e6b6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -366,10 +366,17 @@ fn validate_document_type_references_v0( // Hashed and billed once here, the property named by its path in // the key's params, and handed to the lookup as its key. The create's // structure validation refused a value it could not reveal, before - // any read - let Some(billed_key) = - hash_lookup_key(key, document_type, None, document_data, execution_context) - else { + // any read. The writer is passed in place in generation 0, inert + // before protocol version 14: only meta-schema v3 admits a computed + // key at all, and the `"$ownerId"` param that reads the writer + let Some(billed_key) = hash_lookup_key( + key, + document_type, + None, + owner_id, + document_data, + execution_context, + ) else { return preimage_invalid(); }; let result = validate_reference_v0( @@ -1426,22 +1433,15 @@ fn validate_reference_target_v0( // The commitment a computed key found: it must be old enough, and the create // deletes it when the reference consumes it. Its age is judged in blocks, from its // `$createdAtBlockHeight` (registration demands the type record one) to the - // block of the create, so 1 means an earlier block. A document recording none - // meets a minimum of 1 and no larger one: a contract update cannot make a system - // field required, so it was written before a protocol upgrade re-stored a system - // contract whose type now records one (DPNS v3's preorder at protocol version 14), - // and the upgrade runs before the first transition of its block, so the document - // is from an earlier block, how much earlier unknown. Only a lookup the protocol - // version 14 parser produced has either, so no earlier protocol version reaches this + // block of the create, so 1 means an earlier block; a document recording none + // never meets it. Only a lookup the protocol version 14 parser produced has + // either if let (Some(lookup), Some(found)) = (lookup, referenced_document) { if let Some(blocks) = lookup.minimum_age_blocks { - let old_enough = match found.created_at_block_height() { - Some(created_at) => block_info - .height - .checked_sub(created_at) - .is_some_and(|age| age >= u64::from(blocks)), - None => blocks == 1, - }; + let old_enough = found + .created_at_block_height() + .and_then(|created_at| block_info.height.checked_sub(created_at)) + .is_some_and(|age| age >= u64::from(blocks)); if !old_enough { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentRequirementNotMetError::new( 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 3acf47664a0..7c1a770eb05 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 @@ -425,21 +425,28 @@ impl BilledLookupKey { } } -/// Hashes `key` for a create of a document of `declaring_document_type` -/// carrying `document_data`, `"."` reading `reference_id` (an element's, the -/// writer's or the creator's id; `None` for a property carrying the reference, -/// which the key names by its path), and bills it as the double SHA-256 it is +/// Hashes `key` for a create by `writer_id` of a document of +/// `declaring_document_type` carrying `document_data`, `"."` reading +/// `reference_id` (an element's, the writer's or the creator's id; `None` for a +/// property carrying the reference, which the key names by its path) and +/// `"$ownerId"` the writer, and bills it as the double SHA-256 it is /// (`ValidationOperation::DoubleSha256`, by the blocks it hashes). `None`, with /// nothing hashed or billed, when the preimage cannot be assembled. pub(crate) fn hash_lookup_key( key: &LookupHashKey, declaring_document_type: DocumentTypeRef, reference_id: Option, + writer_id: Identifier, document_data: &BTreeMap, execution_context: &mut StateTransitionExecutionContext, ) -> Option { let (digest, blocks) = key - .key_value(declaring_document_type, reference_id, document_data) + .key_value( + declaring_document_type, + reference_id, + writer_id, + document_data, + ) .ok()?; execution_context.add_operation(ValidationOperation::DoubleSha256(blocks)); Some(BilledLookupKey(digest)) @@ -486,12 +493,15 @@ pub(crate) fn fetch_document_through_lookup( if lookup.resolve_index(document_type).is_err() { return Ok(None); } + // The writer is passed in place, inert before protocol version 14: only meta-schema v3 + // admits a computed key, and the `"$ownerId"` param that reads the writer let billed_key = billed_key.or_else(|| { lookup.hash_key().and_then(|(_, key)| { hash_lookup_key( key, declaring_document_type, Some(reference_value), + owner_id, document_data, execution_context, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index a35d91f709c..bfda340566f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -23,6 +23,7 @@ mod creation_tests { use dpp::document::Document; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::util::hash::hash_double; + use crate::test::helpers::dpns::dpns_salted_domain_hash; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund; @@ -834,17 +835,21 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); let salt_2: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); - - let mut salted_domain_buffer_2: Vec = vec![]; - salted_domain_buffer_2.extend(salt_2); - salted_domain_buffer_2.extend("quantum.dash".as_bytes()); + let salted_domain_hash_1 = dpns_salted_domain_hash( + preorder_document_1.owner_id(), + &salt_1, + "quantum", + "dash", + platform_version, + ); - let salted_domain_hash_2 = hash_double(salted_domain_buffer_2); + let salted_domain_hash_2 = dpns_salted_domain_hash( + preorder_document_2.owner_id(), + &salt_2, + "quantum", + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); @@ -1276,11 +1281,13 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); + let salted_domain_hash_1 = dpns_salted_domain_hash( + preorder_document_1.owner_id(), + &salt_1, + "quantum", + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); @@ -1560,11 +1567,13 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); + let salted_domain_hash_1 = dpns_salted_domain_hash( + preorder_document_1.owner_id(), + &salt_1, + "quantum", + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); @@ -1854,13 +1863,16 @@ mod creation_tests { let salt_3: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_3: Vec = vec![]; - salted_domain_buffer_3.extend(salt_3); - salted_domain_buffer_3.extend("quantum.dash".as_bytes()); - preorder_document_3.set( "saltedDomainHash", - hash_double(salted_domain_buffer_3).into(), + dpns_salted_domain_hash( + preorder_document_3.owner_id(), + &salt_3, + "quantum", + "dash", + platform_version, + ) + .into(), ); document_3.set("preorderSalt", salt_3.into()); @@ -2137,11 +2149,13 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); + let salted_domain_hash_1 = dpns_salted_domain_hash( + preorder_document_1.owner_id(), + &salt_1, + "quantum", + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); @@ -2563,29 +2577,37 @@ mod creation_tests { let salt_3: [u8; 32] = rng.gen(); let salt_4: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); - - let mut salted_domain_buffer_2: Vec = vec![]; - salted_domain_buffer_2.extend(salt_2); - salted_domain_buffer_2.extend("quantum.dash".as_bytes()); - - let salted_domain_hash_2 = hash_double(salted_domain_buffer_2); - - let mut salted_domain_buffer_3: Vec = vec![]; - salted_domain_buffer_3.extend(salt_3); - salted_domain_buffer_3.extend("cry.dash".as_bytes()); + let salted_domain_hash_1 = dpns_salted_domain_hash( + preorder_document_1.owner_id(), + &salt_1, + "quantum", + "dash", + platform_version, + ); - let salted_domain_hash_3 = hash_double(salted_domain_buffer_3); + let salted_domain_hash_2 = dpns_salted_domain_hash( + preorder_document_2.owner_id(), + &salt_2, + "quantum", + "dash", + platform_version, + ); - let mut salted_domain_buffer_4: Vec = vec![]; - salted_domain_buffer_4.extend(salt_4); - salted_domain_buffer_4.extend(format!("{non_contested_normalized_label}.dash").as_bytes()); + let salted_domain_hash_3 = dpns_salted_domain_hash( + preorder_document_3_on_identity_1.owner_id(), + &salt_3, + "cry", + "dash", + platform_version, + ); - let salted_domain_hash_4 = hash_double(salted_domain_buffer_4); + let salted_domain_hash_4 = dpns_salted_domain_hash( + preorder_document_4_on_identity_1.owner_id(), + &salt_4, + &non_contested_normalized_label, + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); @@ -3207,22 +3229,27 @@ mod creation_tests { let contested_salt: [u8; 32] = rng.gen(); let non_contested_salt: [u8; 32] = rng.gen(); - let mut contested_salted_domain_buffer: Vec = vec![]; - contested_salted_domain_buffer.extend(contested_salt); - contested_salted_domain_buffer.extend(format!("{contested_label}.dash").as_bytes()); - - let mut non_contested_salted_domain_buffer: Vec = vec![]; - non_contested_salted_domain_buffer.extend(non_contested_salt); - non_contested_salted_domain_buffer - .extend(format!("{non_contested_normalized_label}.dash").as_bytes()); - contested_preorder_document.set( "saltedDomainHash", - hash_double(contested_salted_domain_buffer).into(), + dpns_salted_domain_hash( + contested_preorder_document.owner_id(), + &contested_salt, + contested_label, + "dash", + platform_version, + ) + .into(), ); non_contested_preorder_document.set( "saltedDomainHash", - hash_double(non_contested_salted_domain_buffer).into(), + dpns_salted_domain_hash( + non_contested_preorder_document.owner_id(), + &non_contested_salt, + non_contested_normalized_label, + "dash", + platform_version, + ) + .into(), ); contested_document.set("preorderSalt", contested_salt.into()); non_contested_document.set("preorderSalt", non_contested_salt.into()); 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 4348c8449ea..1150ca7c52d 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 @@ -1029,13 +1029,13 @@ mod dpns_tests { mod dpns_username_transfer_tests { use super::*; + use crate::test::helpers::dpns::dpns_salted_domain_hash; use dpp::consensus::basic::BasicError; use dpp::consensus::state::data_trigger::DataTriggerError; use dpp::data_contract::DataContract; use dpp::data_contracts::SystemDataContract; use dpp::document::Document; use dpp::identity::{Identity, IdentityPublicKey}; - use dpp::util::hash::hash_double; use dpp::util::strings::convert_to_homograph_safe_chars; use drive::query::drive_document_average_query::{ AverageMode, DocumentAverageRequest, DocumentAverageResponse, @@ -1130,11 +1130,13 @@ mod dpns_username_transfer_tests { let salt: [u8; 32] = rng.gen(); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label + ".dash").as_bytes()); - - let salted_domain_hash = hash_double(salted_domain_buffer); + let salted_domain_hash = dpns_salted_domain_hash( + document.owner_id(), + &salt, + &normalized_label, + "dash", + platform_version, + ); preorder_document.set("saltedDomainHash", salted_domain_hash.into()); document.set("preorderSalt", salt.into()); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index 088b11d9bad..01659645103 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -15,29 +15,28 @@ mod dpns_v3_tests { create_dpns_name_contest_give_key_info, perform_votes_multi, }; use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::dpns::dpns_salted_domain_hash; use crate::test::helpers::setup::TempPlatform; use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; use dpp::consensus::basic::BasicError; use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::state::data_trigger::DataTriggerError; - use dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dpp::data_contract::DataContract; use dpp::data_contracts::SystemDataContract; use dpp::document::Document; use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; - use dpp::platform_value::string_encoding::Encoding; use dpp::prelude::{Identifier, IdentityNonce}; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; use dpp::state_transition::StateTransition; use dpp::system_data_contracts::dpns_contract; - use dpp::util::hash::hash_double; use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; + use drive::drive::RootTree; + use drive::grovedb::Element; use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; - use drive::util::storage_flags::StorageFlags; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; use simple_signer::signer::SimpleSigner; @@ -191,25 +190,11 @@ mod dpns_v3_tests { values: &[(&str, Value)], absent: &[&str], height: u64, - ) -> (Document, StateTransitionExecutionResult) { - let dpns = self.dpns(); - self.create_in(&dpns, who, type_name, values, absent, height) - .await - } - - /// [`Self::create`] of a document of `contract`. - async fn create_in( - &mut self, - contract: &DataContract, - who: Who, - type_name: &str, - values: &[(&str, Value)], - absent: &[&str], - height: u64, ) -> (Document, StateTransitionExecutionResult) { let platform_version = self.platform_version; let owner_id = self.id(who); - let document_type = contract + let dpns = self.dpns(); + let document_type = dpns .document_type_for_name(type_name) .expect("expected the document type"); let entropy = Bytes32::random_with_rng(&mut self.rng); @@ -290,7 +275,14 @@ mod dpns_v3_tests { "preorder", &[( "saltedDomainHash", - salted_domain_hash(&salt, label, parent).into(), + dpns_salted_domain_hash( + self.id(who), + &salt, + &convert_to_homograph_safe_chars(label), + parent, + self.platform_version, + ) + .into(), )], &[], height, @@ -378,15 +370,30 @@ mod dpns_v3_tests { .pop() } - /// The preorder whose salted hash is `preorder`'s, if still stored. + /// The stored preorder `preorder` is, found by its unique index: its owner + /// and salted hash from DPNS v3, its salted hash before it. fn stored_preorder(&self, preorder: &Document) -> Option { let salted_domain_hash = preorder .properties() .get("saltedDomainHash") .expect("expected the preorder's salted hash") .clone(); - self.query("preorder", &[("saltedDomainHash", salted_domain_hash)]) + if self.platform_version.system_data_contracts.dpns >= 3 { + self.query( + "preorder", + &[ + ( + "$ownerId", + Value::Identifier(preorder.owner_id().to_buffer()), + ), + ("saltedDomainHash", salted_domain_hash), + ], + ) .pop() + } else { + self.query("preorder", &[("saltedDomainHash", salted_domain_hash)]) + .pop() + } } /// Runs the protocol version 14 activation at block `height` on a @@ -487,76 +494,32 @@ mod dpns_v3_tests { .documents_owned() } - /// Registers a contract, owned by Alice, whose `claim` reveals a DPNS preorder - /// (`preorderSalt` refers to it, found by the same hash) at least - /// `minimum_age_blocks` old, and returns it. - fn register_preorder_claims_contract( - fixture: &DpnsFixture, - minimum_age_blocks: u32, - ) -> DataContract { - let contract_json = serde_json::json!({ - "$formatVersion": "1", - "id": Identifier::new([0x5C; 32]).to_string(Encoding::Base58), - "ownerId": fixture.id(Who::Alice).to_string(Encoding::Base58), - "version": 1, - "documentSchemas": { - "claim": { - "type": "object", - "documentsMutable": false, - "canBeDeleted": false, - "properties": { - "label": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, - "parent": { "type": "string", "maxLength": 63, "position": 1 }, - "preorderSalt": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, - "position": 2, - "refersTo": { - "type": "deletableDocument", - "contractId": SystemDataContract::DPNS.id().to_string(Encoding::Base58), - "documentType": "preorder", - "findBy": { - "saltedDomainHash": { - "function": "sys.hash.sha256d", - "params": ["preorderSalt", "label", { "const": "." }, "parent"] - } - }, - "minimumAgeBlocks": minimum_age_blocks - } - } - }, - "required": ["label", "parent", "preorderSalt"], - "additionalProperties": false - } - } - }); - let contract = DataContract::from_json(contract_json, true, fixture.platform_version) - .expect("expected to parse the claims contract"); - fixture - .platform - .drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - fixture.platform_version, - ) - .expect("expected to apply the claims contract"); - contract - } - - /// The commitment a preorder stores, as every client computes it: - /// `sha256d(salt ++ normalizedLabel ++ "." ++ parentDomainName)`. - fn salted_domain_hash(salt: &[u8; 32], label: &str, parent: &str) -> [u8; 32] { - let mut buffer = salt.to_vec(); - buffer.extend(convert_to_homograph_safe_chars(label).as_bytes()); - buffer.extend(b"."); - buffer.extend(parent.as_bytes()); - hash_double(buffer) + /// The elements laying out the DPNS `preorder` type: its tree, its primary key + /// tree, its `ownerAndSaltedHash` index tree, and whatever its v2 `saltedHash` + /// index left under `saltedDomainHash`. + fn preorder_type_layout( + platform: &TempPlatform, + platform_version: &PlatformVersion, + ) -> (Element, Element, Element, Option) { + let dpns_id = SystemDataContract::DPNS.id().to_buffer(); + let documents_root: &[u8] = Into::<&[u8; 1]>::into(RootTree::DataContractDocuments); + let documents_path: [&[u8]; 3] = [documents_root, &dpns_id, &[1]]; + let type_path: [&[u8]; 4] = [documents_root, &dpns_id, &[1], b"preorder"]; + let grove_version = &platform_version.drive.grove_version; + let get = |path: &[&[u8]], key: &[u8]| { + platform + .drive + .grove + .get_raw_optional(path.into(), key, None, grove_version) + .unwrap() + .expect("expected to read the preorder layout") + }; + ( + get(&documents_path, b"preorder").expect("expected the preorder type tree"), + get(&type_path, &[0]).expect("expected the primary key tree"), + get(&type_path, b"$ownerId").expect("expected the owner index tree"), + get(&type_path, b"saltedDomainHash"), + ) } /// The consensus error of a refused write, printed with its code. @@ -668,18 +631,87 @@ mod dpns_v3_tests { .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) .await; - // Bob read Alice's salt off her domain create and races it + // Bob read Alice's salt off her domain create and races it: his reveal hashes his + // own id and looks among his own preorders, so it finds nothing let (_, result) = fixture .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) .await; assert_matches!( refusal(&result), - ConsensusError::StateError(StateError::ReferencedDocumentPropertyMismatchError(_)) + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) ); assert!(fixture.name("quantum7").is_none()); assert!(fixture.stored_preorder(&preorder).is_some()); } + /// Preorders are unique per owner and their hash binds the owner, so a copy of + /// someone's preorder is harmless: stored first, it does not stop the owner's own + /// preorder, and the copier, who learns the salt from the owner's reveal, cannot + /// reveal the name with it. + #[tokio::test] + async fn should_neither_block_nor_reveal_a_name_with_a_copied_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let alice = fixture.id(Who::Alice); + let salt: [u8; 32] = fixture.rng.gen(); + let alice_hash = + dpns_salted_domain_hash(alice, &salt, "quantum7", "dash", fixture.platform_version); + + // Bob copies Alice's pending commitment, and his copy lands first + let (copy, result) = fixture + .create( + Who::Bob, + "preorder", + &[("saltedDomainHash", alice_hash.into())], + &[], + PREORDER_HEIGHT, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + + // Bob races Alice's reveal with her salt and his copy + let (_, result) = fixture + .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) + ); + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!( + fixture + .name("quantum7") + .expect("expected the name") + .owner_id(), + alice + ); + assert!(fixture.stored_preorder(&preorder).is_none()); + assert!( + fixture.stored_preorder(©).is_some(), + "the copy is Bob's, untouched" + ); + } + #[tokio::test] async fn should_refuse_a_name_without_its_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); @@ -938,12 +970,13 @@ mod dpns_v3_tests { ); } - /// A chain upgraded from protocol version 13: DPNS is re-stored as v3, - /// every domain and preorder written under v2 decodes unchanged, and a - /// preorder made before the upgrade, which records no creation height, is - /// old enough to reveal. + /// A chain upgraded from protocol version 13: DPNS is re-stored as v3, every + /// domain written under v2 decodes unchanged, and the `preorder` type is + /// rebuilt: every preorder made before the upgrade is gone, a pending one can + /// no longer be revealed, and the type is laid out exactly as on a chain born + /// at protocol version 14. #[tokio::test] - async fn should_decode_v2_documents_and_reveal_a_v2_preorder_after_the_upgrade() { + async fn should_decode_v2_domains_and_rebuild_the_preorders_at_the_upgrade() { let mut fixture = DpnsFixture::new(13); let (_, registered, result) = fixture.register(Who::Bob, "quantum7", &[], &[]).await; @@ -957,13 +990,10 @@ mod dpns_v3_tests { .await; let v2_domain = fixture.name("quantum7").expect("expected the v2 domain"); - let v2_preorder = fixture - .stored_preorder(&pending) - .expect("expected the v2 preorder"); assert_eq!(v2_domain.id(), registered.id()); - assert_eq!(v2_preorder.created_at_block_height(), None); - let v2_dpns = fixture.dpns(); - assert!(v2_dpns + assert!(fixture.stored_preorder(&pending).is_some()); + assert!(fixture + .dpns() .document_type_for_name("domain") .expect("expected the domain document type") .documents_can_be_deleted()); @@ -971,10 +1001,10 @@ mod dpns_v3_tests { fixture.activate_protocol_version_14(20); let v3_dpns = fixture.dpns(); - let v3_domain_type = v3_dpns + assert!(!v3_dpns .document_type_for_name("domain") - .expect("expected the domain document type"); - assert!(!v3_domain_type.documents_can_be_deleted()); + .expect("expected the domain document type") + .documents_can_be_deleted()); assert!(v3_dpns .document_type_for_name("preorder") .expect("expected the preorder document type") @@ -985,53 +1015,27 @@ mod dpns_v3_tests { v2_domain, "a domain written under v2 decodes unchanged under v3" ); - assert_eq!( - fixture - .stored_preorder(&pending) - .expect("expected the preorder"), - v2_preorder, - "a preorder written under v2 decodes unchanged under v3" - ); - // Another contract revealing DPNS preorders, three blocks old at least: the upgrade - // shows only that a preorder recording no height is from an earlier block, so - // Alice's, made a block before the upgrade, meets no minimum above 1 - let claims = register_preorder_claims_contract(&fixture, 3); - let (_, result) = fixture - .create_in( - &claims, - Who::Alice, - "claim", - &[ - ("label", Value::Text("quantum8".into())), - ("parent", Value::Text("dash".into())), - ("preorderSalt", salt.into()), - ], - &[], - 21, - ) - .await; - assert_matches!( - refusal(&result), - ConsensusError::StateError(StateError::ReferencedDocumentRequirementNotMetError(e)) - if e.field() == "minimumAgeBlocks" && e.required() == "3" + // The rebuilt preorder type holds nothing and is laid out as a chain born at + // protocol version 14 lays it out + assert!(fixture.stored_preorder(&pending).is_none()); + let born_at_14 = DpnsFixture::new(14); + assert_eq!( + preorder_type_layout(&fixture.platform, fixture.platform_version), + preorder_type_layout(&born_at_14.platform, born_at_14.platform_version) ); - // DPNS asks for one block, which it is + // Alice's pending preorder is gone, so she preorders again let (_, result) = fixture .domain(Who::Alice, salt, "quantum8", "dash", &[], &[], 21) .await; assert_matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. } + refusal(&result), + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) ); - assert!(fixture.name("quantum8").is_some()); - assert!(fixture.stored_preorder(&pending).is_none()); - - // A preorder made from protocol version 14 on records its height let salt: [u8; 32] = fixture.rng.gen(); let preorder = fixture - .preorder(Who::Alice, salt, "quantum9", "dash", 22) + .preorder(Who::Alice, salt, "quantum8", "dash", 22) .await; assert_eq!( fixture @@ -1040,6 +1044,14 @@ mod dpns_v3_tests { .created_at_block_height(), Some(22) ); + let (_, result) = fixture + .domain(Who::Alice, salt, "quantum8", "dash", &[], &[], 23) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(fixture.stored_preorder(&preorder).is_none()); } /// A chain born at protocol version 14 stores DPNS v3 and still inserts @@ -1147,7 +1159,13 @@ mod dpns_v3_tests { query_dpns( &platform, "preorder", - &[("saltedDomainHash", salted_domain_hash)], + &[ + ( + "$ownerId", + Value::Identifier(preorder.owner_id().to_buffer()) + ), + ("saltedDomainHash", salted_domain_hash), + ], platform_version, ) .is_empty(), @@ -1258,7 +1276,7 @@ mod dpns_v3_tests { measured, vec![ (1_876_280, 42_660_000, 0), - (2_318_400, 42_687_000, 15_181_239) + (2_525_540, 42_687_000, 15_181_239) ] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs index 282e6cbd02a..3ba79d72233 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs @@ -7,6 +7,7 @@ use super::*; use crate::platform_types::platform_state::PlatformState; use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::dpns::dpns_salted_domain_hash; use crate::test::helpers::setup::TempPlatform; use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::document::Document; @@ -18,7 +19,6 @@ use dpp::state_transition::batch_transition::document_create_transition::Documen use dpp::state_transition::batch_transition::{BatchTransitionV0, DocumentCreateTransition}; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; -use dpp::util::hash::hash_double; use dpp::util::strings::convert_to_homograph_safe_chars; use drive::drive::Drive; use drive::query::{SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; @@ -523,8 +523,13 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { // DPNS registers a name in two steps: the salted preorder, then the domain. let name = "quantum"; let salt: [u8; 32] = rng.gen(); - let mut salted_domain = salt.to_vec(); - salted_domain.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); + let salted_domain_hash = dpns_salted_domain_hash( + identity.id(), + &salt, + &convert_to_homograph_safe_chars(name), + "dash", + platform_version, + ); let preorder_entropy = Bytes32::random_with_rng(&mut rng); let mut preorder_document = preorder .random_document_with_identifier_and_entropy( @@ -536,7 +541,7 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { platform_version, ) .expect("expected a random preorder document"); - preorder_document.set("saltedDomainHash", hash_double(salted_domain).into()); + preorder_document.set("saltedDomainHash", salted_domain_hash.into()); preorder_document .set_id_for_creation(preorder, &preorder_entropy.0, 2, platform_version) .expect("expected to set the preorder id"); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index d6e3a6bc7da..bb52c79507e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -126,6 +126,7 @@ pub(crate) mod test_helpers; #[cfg(test)] pub(in crate::execution) mod tests { use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::dpns::dpns_salted_domain_hash; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; use dpp::data_contracts::SystemDataContract; @@ -1350,17 +1351,21 @@ pub(in crate::execution) mod tests { let salt_1: [u8; 32] = rng.gen(); let salt_2: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); - - let mut salted_domain_buffer_2: Vec = vec![]; - salted_domain_buffer_2.extend(salt_2); - salted_domain_buffer_2.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); + let salted_domain_hash_1 = dpns_salted_domain_hash( + document_1.owner_id(), + &salt_1, + &convert_to_homograph_safe_chars(name), + "dash", + platform_version, + ); - let salted_domain_hash_2 = hash_double(salted_domain_buffer_2); + let salted_domain_hash_2 = dpns_salted_domain_hash( + document_2.owner_id(), + &salt_2, + &convert_to_homograph_safe_chars(name), + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); @@ -1981,11 +1986,13 @@ pub(in crate::execution) mod tests { let salt_1: [u8; 32] = rng.gen(); - let mut salted_domain_buffer_1: Vec = vec![]; - salted_domain_buffer_1.extend(salt_1); - salted_domain_buffer_1.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); - - let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); + let salted_domain_hash_1 = dpns_salted_domain_hash( + document_1.owner_id(), + &salt_1, + &convert_to_homograph_safe_chars(name), + "dash", + platform_version, + ); preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); diff --git a/packages/rs-drive-abci/src/test/helpers/dpns.rs b/packages/rs-drive-abci/src/test/helpers/dpns.rs new file mode 100644 index 00000000000..46e2317e815 --- /dev/null +++ b/packages/rs-drive-abci/src/test/helpers/dpns.rs @@ -0,0 +1,24 @@ +use dpp::prelude::Identifier; +use dpp::util::hash::hash_double; +use dpp::version::PlatformVersion; + +/// The `saltedDomainHash` a DPNS preorder by `owner_id` commits to for the name +/// `normalized_label` under `parent`, as the DPNS system contract of +/// `platform_version` reveals it: from DPNS v3 (protocol version 14) the double +/// SHA-256 of the owner's id, the salt, the normalized label, `"."` and the +/// parent; before it, of the salt and `.`. +pub fn dpns_salted_domain_hash( + owner_id: Identifier, + salt: &[u8; 32], + normalized_label: &str, + parent: &str, + platform_version: &PlatformVersion, +) -> [u8; 32] { + let mut preimage = vec![]; + if platform_version.system_data_contracts.dpns >= 3 { + preimage.extend(owner_id.to_buffer()); + } + preimage.extend(salt); + preimage.extend(format!("{normalized_label}.{parent}").as_bytes()); + hash_double(preimage) +} diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index ebc1ad5982b..fdd7849db0e 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -1,6 +1,9 @@ /// Contract group fixtures #[cfg(test)] pub mod contract_groups; +/// DPNS fixtures +#[cfg(test)] +pub mod dpns; /// Test helpers #[cfg(test)] pub mod fast_forward_to_block; diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index 15062fa5c9e..d1acf8ad760 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -55,18 +55,49 @@ mod tests { salt: [u8; 32], ) -> (DocumentOp, DocumentOp) { let normalized_label = convert_to_homograph_safe_chars(label); - // sha256d(salt ++ normalizedLabel ++ "." ++ parentDomainName) - let mut salted_domain_name = salt.to_vec(); - salted_domain_name.extend(normalized_label.as_bytes()); - salted_domain_name.extend(b".dash"); + let domain_values: BTreeMap = BTreeMap::from([ + ("label".into(), label.into()), + ("normalizedLabel".into(), normalized_label.clone().into()), + ("parentDomainName".into(), "dash".into()), + ("normalizedParentDomainName".into(), "dash".into()), + ("preorderSalt".into(), salt.into()), + ( + "records".into(), + BTreeMap::from([("identity", Value::from(owner_id))]).into(), + ), + ]); + // The hash the domain's `preorderSalt` declares from DPNS v3 (the owner's id, the + // salt, the normalized label, "." and the parent); before it, of the salt and + // `.dash` + let domain_type = dpns_contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let declared_key = domain_type + .flattened_properties() + .get("preorderSalt") + .and_then(|property| property.revealed_reference.as_ref()) + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + .and_then(|lookup| lookup.hash_key()) + .map(|(_, key)| key); + let salted_domain_hash = match declared_key { + Some(key) => { + key.key_value(domain_type, None, owner_id, &domain_values) + .expect("expected the preorder hash") + .0 + } + None => { + let mut salted_domain_name = salt.to_vec(); + salted_domain_name.extend(normalized_label.as_bytes()); + salted_domain_name.extend(b".dash"); + hash_double(salted_domain_name) + } + }; let preorder_op = DocumentOp { contract: dpns_contract.clone(), action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([( - "saltedDomainHash".into(), - hash_double(salted_domain_name).into(), - )]), + BTreeMap::from([("saltedDomainHash".into(), salted_domain_hash.into())]), Some(owner_id), DocumentFieldFillType::FillIfNotRequired, DocumentFieldFillSize::AnyDocumentFillSize, @@ -80,17 +111,7 @@ mod tests { let domain_op = DocumentOp { contract: dpns_contract.clone(), action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([ - ("label".into(), label.into()), - ("normalizedLabel".into(), normalized_label.into()), - ("parentDomainName".into(), "dash".into()), - ("normalizedParentDomainName".into(), "dash".into()), - ("preorderSalt".into(), salt.into()), - ( - "records".into(), - BTreeMap::from([("identity", Value::from(owner_id))]).into(), - ), - ]), + domain_values, Some(owner_id), DocumentFieldFillType::FillIfNotRequired, DocumentFieldFillSize::AnyDocumentFillSize, diff --git a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs new file mode 100644 index 00000000000..a57de73c32b --- /dev/null +++ b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs @@ -0,0 +1,105 @@ +use crate::drive::{contract_documents_path, Drive}; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::serialization::PlatformSerializableWithPlatformVersion; +use dpp::version::PlatformVersion; +use grovedb::operations::delete::DeleteOptions; +use grovedb::{Element, Transaction}; +use std::borrow::Cow; + +impl Drive { + /// Re-stores `contract` over the stored version of the same contract, as `apply_contract` + /// does, and rebuilds each document type `rebuilt_document_types` names from nothing: + /// its whole subtree, every document and index entry under it, is deleted, and the update + /// then creates the type as it creates a type a contract update adds, which is how a + /// contract registration creates it too. The rebuilt types end up holding no documents, + /// laid out exactly as on a chain that registered `contract` directly. + /// + /// For a protocol upgrade that changes a system contract's document type in a way a + /// contract update cannot follow, such as replacing a unique index; the documents deleted + /// are not refunded. Runs once, on the first block of the protocol version that ships the + /// new contract (DPNS v3's `preorder` at protocol version 14). + pub fn apply_contract_rebuilding_document_types( + &self, + contract: &DataContract, + rebuilt_document_types: &[&str], + block_info: &BlockInfo, + storage_flags: Option>, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let contract_id = contract.id().to_buffer(); + let stored_contract = self + .fetch_contract_and_add_operations( + contract_id, + None, + Some(transaction), + &mut vec![], + platform_version, + )? + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a contract is rebuilt over its stored version, which must exist", + )) + })? + .contract + .clone(); + + // The update compares against the stored contract without the rebuilt types, so it + // creates each of them as a type the update adds + let mut original_contract = stored_contract; + for document_type_name in rebuilt_document_types { + if original_contract + .document_types_mut() + .remove(*document_type_name) + .is_none() + || !contract.has_document_type_for_name(document_type_name) + { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a rebuilt document type must be in both the stored and the new contract", + ))); + } + self.grove + .delete( + contract_documents_path(&contract_id).as_ref(), + document_type_name.as_bytes(), + Some(DeleteOptions { + allow_deleting_non_empty_trees: true, + deleting_non_empty_trees_returns_error: false, + ..Default::default() + }), + Some(transaction), + &platform_version.drive.grove_version, + ) + .unwrap() + .map_err(Error::from)?; + } + + let contract_element = Element::Item( + contract.serialize_to_bytes_with_platform_version(platform_version)?, + StorageFlags::map_cow_to_some_element_flags(storage_flags), + ); + let mut drive_operations = vec![]; + self.update_contract_add_operations( + contract_element, + contract, + &original_contract, + block_info, + &mut None, + Some(transaction), + &mut drive_operations, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + Some(transaction), + drive_operations, + &mut vec![], + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/contract/migration/mod.rs b/packages/rs-drive/src/drive/contract/migration/mod.rs index 4bf094f6ad0..08460649f9b 100644 --- a/packages/rs-drive/src/drive/contract/migration/mod.rs +++ b/packages/rs-drive/src/drive/contract/migration/mod.rs @@ -1,2 +1,3 @@ mod add_version_items_to_all_contracts; +mod apply_contract_rebuilding_document_types; mod strip_unknown_document_schema_properties; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index e97ae8897d4..d0491c55d67 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -10,9 +10,9 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // `distributionType`, written for once-per-identity distribution claims. // The DPNS contract v3 checks a domain create with schema keywords instead of // most of its data trigger: generated normalized names, the reveal and -// deletion of the writer's own preorder from an earlier block, and the new -// domain's identity record held to its owner. Domains can no longer be -// deleted. v2 (dashpay: 1, withdrawals: 1, token_history: 1, dpns: 2) remains +// deletion of the writer's own preorder from an earlier block (preorders unique +// per owner, their hash binding the owner), and the new domain's identity record +// held to its owner. Domains can no longer be deleted. v2 (dashpay: 1, withdrawals: 1, token_history: 1, dpns: 2) remains // for PROTOCOL_VERSION_13 chain replay. // // The app-connect contract (app_connect: 1) also activates with diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 04c821f8788..e26388f2dac 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1599,8 +1599,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the document is found by that property holding the SHA-256 of the /// SHA-256 of the params' bytes joined in order, a property path of the /// document (the property carrying the reference included), -/// `{ "const": text }`, or `"."` for a value without a path (each element -/// of a typed array, the writer, the creator), a string counting as its +/// `{ "const": text }`, `"."` for a value without a path (each element +/// of a typed array, the writer, the creator), or `"$ownerId"` for the +/// writer's id (`LookupKeyParam::Writer`), a string counting as its /// UTF-8, a byte array as its bytes, an identifier as its 32 bytes. The /// parser holds the function as the lookup's computed key /// (`LookupKeySource::Hash`). The function is `SystemFunction::Hash`, a @@ -1619,7 +1620,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// function the reference may declare `minimumAgeBlocks`, judged by /// document create state validation 2 against the found document's /// `$createdAtBlockHeight` (`ReferencedDocumentRequirementNotMetError`, -/// 40142; a found document recording none meets a minimum of 1 only, 89), and +/// 40142), and /// `consume`, which deletes the found document with the create /// (`DocumentCreateTransitionAction` `consumed_documents`, a batch touching /// it elsewhere refused with 40120). The hash is computed once per key and @@ -1628,7 +1629,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// on the create alone, may sit on an `immutable` property, which /// `validate_no_immutable_deletable_element_references` otherwise refuses. /// A `where` entry `{"$ownerId": "$ownerId"}` makes the commitment the -/// writer's own, and `consume` requires it, into the declaring contract, +/// writer's own, and so does a `findBy` entry `{"$ownerId": "$ownerId"}` +/// through a unique index over the owner and the hash, which beside a +/// function a transferable referring type may declare too, the key being +/// judged on the create alone; `consume` requires one of the two, into the declaring contract, /// on a type whose owners may delete, that keeps no history, declares no /// delete token cost or delete action fee and requires no stricter /// signature security level than the declaring type; batch advanced @@ -1643,8 +1647,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `moderatorAbilities.changeFields` (57). `creatorRefersTo` takes a /// `deletableDocument` target through a function, and the `findBy` of an /// `ownerRefersTo` or `creatorRefersTo` may leave the value out beside -/// one. The declaration reproduces the DPNS preorder hash of a name under -/// a parent byte for byte, and DPNS v3 declares it (89). See +/// one. DPNS v3 declares such a reveal (89). See /// `book/src/data-model/documents.md`. /// /// 62. **Null flags follow each index's own path**: the v2 index-level @@ -2247,38 +2250,40 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 89. **DPNS v3 checks a name's create with keywords**: /// `SYSTEM_DATA_CONTRACT_VERSIONS_V3` selects DPNS contract v3, loaded at -/// genesis (which still inserts the `dash` top-level domain) and re-stored -/// by `transition_to_version_14` with `apply_contract`. Its `domain` +/// genesis (which still inserts the `dash` top-level domain). Its `domain` /// generates `normalizedLabel` and `normalizedParentDomainName` with /// `sys.stringTransformations.homographSafeASCII` (53; a value sent that /// differs is `DocumentPropertyNotGeneratedError`, 10424). Its /// `preorderSalt` reveals the writer's own `preorder` from an earlier block -/// and deletes it, the storage refunded to the writer (61: `findBy` the -/// sha256d of the salt, the normalized label, `"."` and the parent, -/// `where` `$ownerId`, `minimumAgeBlocks: 1`, `consume`; refused with 40120, -/// 40127 or 40142). The rule `recordsIdentityIsOwner` (`$transferredAt` -/// equal to `$createdAt` implies `records.identity` equal to `$ownerId`) -/// holds a new name's identity record to its owner (10422); a transfer or -/// a purchase in a later block, judged with its own time, no longer reads -/// the record, and one in the block that created the name is refused. The -/// `domain` sets `canBeDeleted: false` and the `preorder` requires -/// `$createdAtBlockHeight`; property positions, the transient list and the -/// stored encoding are v2's, so stored domains and preorders decode -/// unchanged. `create_domain_data_trigger` 2 +/// and deletes it, the storage refunded to the writer (61: `findBy` +/// `$ownerId` and the sha256d of the writer's id, the salt, the normalized +/// label, `"."` and the parent, `minimumAgeBlocks: 1`, `consume`; refused +/// with 40120 or 40142). Preorders are unique per owner (`ownerAndSaltedHash` +/// over `$ownerId` and `saltedDomainHash`, replacing `saltedHash`), so a +/// copy of a pending preorder neither blocks it nor, its hash binding the +/// owner, lets the copier reveal the name once the salt is public. The rule +/// `recordsIdentityIsOwner` (`$transferredAt` equal to `$createdAt` implies +/// `records.identity` equal to `$ownerId`) holds a new name's identity +/// record to its owner (10422); a transfer or a purchase in a later block, +/// judged with its own time, no longer reads the record, and one in the +/// block that created the name is refused. The `domain` sets +/// `canBeDeleted: false` and the `preorder` requires +/// `$createdAtBlockHeight`; the `domain`'s property positions, transient +/// list and stored encoding are v2's, so stored domains decode unchanged. +/// `transition_to_version_14` re-stores DPNS through +/// `Drive::apply_contract_rebuilding_document_types`: every preorder made +/// before the upgrade is deleted, unrefunded, and the `preorder` type is +/// rebuilt as the contract update creates a type it adds, the layout a +/// chain born at this version has. `create_domain_data_trigger` 2 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`) keeps only the parent domain /// checks: a top-level domain only by the contract owner, the parent /// present, no subdomains allowed under a name, and the parent's subdomain /// rule. Data trigger bindings 2, in place, drop the `domain` Replace and /// Delete rejects; `documentsMutable` and `canBeDeleted` false refuse both -/// (`InvalidDocumentTransitionActionError`, 10404). The reference -/// validation 0, in place, lets a found document recording no -/// `$createdAtBlockHeight` meet `minimumAgeBlocks: 1` and no larger -/// minimum: an update can not make a system field required, so only a -/// system contract a protocol upgrade re-stored holds such documents, all -/// written in some block before the upgrade's, and the preorders made -/// before this version stay revealable. Inert before this version: only -/// the version 14 parser produces a `minimumAgeBlocks`, and the earlier -/// tables select DPNS v1 or v2, trigger 0 or 1 and bindings 0 or 1. +/// (`InvalidDocumentTransitionActionError`, 10404). The writer reaches the +/// computed key's preimage through `hash_lookup_key`, in place in reference +/// validation 0 and batch state 0, inert before this version: only +/// meta-schema v3 admits a computed key, and the `"$ownerId"` param. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 8190ed30bb2..4432b971df3 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -14,6 +14,8 @@ use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; @@ -56,6 +58,45 @@ fn hash_double(data: Vec) -> [u8; 32] { hash.to_byte_array() } +/// The `saltedDomainHash` a preorder by `owner_id` commits to, for a domain +/// carrying `domain_properties` (its `preorderSalt`, labels and parent), as the +/// DPNS contract the network runs declares it. From DPNS v3 the domain's +/// `preorderSalt` reveals the hash of the writer's id, the salt, the normalized +/// label, `"."` and the parent, and the declaration computes it, as the platform +/// does; before it the hash is of the salt and `.dash`. +fn salted_domain_hash( + domain_document_type: DocumentTypeRef, + owner_id: Identifier, + salt: [u8; 32], + normalized_label: &str, + domain_properties: &BTreeMap, +) -> Result<[u8; 32], Error> { + let declared_key = domain_document_type + .flattened_properties() + .get("preorderSalt") + .and_then(|property| property.revealed_reference.as_ref()) + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + .and_then(|lookup| lookup.hash_key()) + .map(|(_, key)| key); + match declared_key { + Some(key) => key + .key_value(domain_document_type, None, owner_id, domain_properties) + .map(|(hash, _)| hash) + .map_err(|error| { + Error::Generic(format!( + "cannot compute the DPNS preorder hash: {} {}", + error.param, error.reason + )) + }), + None => { + let mut salted_domain_buffer = salt.to_vec(); + salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); + Ok(hash_double(salted_domain_buffer)) + } + } +} + /// Callback type for preorder document pub type PreorderCallback = Box; @@ -168,12 +209,46 @@ impl Sdk { let preorder_id = Identifier::default(); let domain_id = Identifier::default(); - // Create salted domain hash for preorder let normalized_label = convert_to_homograph_safe_chars(&input.label); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); + let domain_properties = BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ("label".to_string(), Value::Text(input.label.clone())), + ( + "normalizedLabel".to_string(), + Value::Text(normalized_label.clone()), + ), + ("preorderSalt".to_string(), Value::Bytes32(salt)), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(identity_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]); + + // The salted domain hash the preorder commits to + let salted_domain_hash = salted_domain_hash( + domain_document_type, + identity_id, + salt, + &normalized_label, + &domain_properties, + )?; // Create preorder document let preorder_document = Document::V0(DocumentV0 { @@ -204,36 +279,7 @@ impl Sdk { contract_version: None, id: domain_id, owner_id: identity_id, - properties: BTreeMap::from([ - ( - "parentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ( - "normalizedParentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ("label".to_string(), Value::Text(input.label.clone())), - ( - "normalizedLabel".to_string(), - Value::Text(normalized_label.clone()), - ), - ("preorderSalt".to_string(), Value::Bytes32(salt)), - ( - "records".to_string(), - Value::Map(vec![( - Value::Text("identity".to_string()), - Value::Identifier(identity_id.to_buffer()), - )]), - ), - ( - "subdomainRules".to_string(), - Value::Map(vec![( - Value::Text("allowSubdomains".to_string()), - Value::Bool(false), - )]), - ), - ]), + properties: domain_properties, revision: None, created_at: None, updated_at: None, diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index e899a8f319d..b0a5117fe0b 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -430,6 +430,7 @@ fn find_by_function_to_js(key: &LookupHashKey, path: &str) -> WasmDppResult JsValue::from_str("$ownerId"), }; params.push(¶m_value); } From b66668f40cde1a0374353ea901ebc52227db0d9e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 09:33:40 +0200 Subject: [PATCH 06/62] fix(sdk): hash DPNS preorders against the contract the network stores A registration took the preorder's hash form from the DPNS contract the client already held: the context provider's for the SDK's protocol version (13 on a fresh SDK), or js-dash-sdk's app cache. Either could still be DPNS v2 after the network moved to v3, so the preorder committed to the old hash and the domain create could never find it (40120), after the preorder was paid. rs-sdk now refreshes the protocol version (proven) and fetches DPNS from state before hashing, fetching again when the response raised the version the contract was parsed under. A preorderSalt reference without a findBy function is an error instead of a silent fall back to the old hash. js-dash-sdk fetches the contract with a new `skipCache` option, which also replaces the cached contract both documents are created against. Tests: the SDK hash against the real DPNS v2 and v3 contracts, a missing hashed property, and a mock SDK seeded at 13 with a provider holding v2; js specs for a cache warmed with v2 and for `skipCache`. Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/contracts/get.spec.ts | 9 + .../Client/Platform/methods/contracts/get.ts | 21 +- .../Platform/methods/names/register.spec.ts | 90 +++++-- .../Client/Platform/methods/names/register.ts | 7 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 226 ++++++++++++++++-- packages/rs-sdk/src/sdk.rs | 4 +- 6 files changed, 308 insertions(+), 49 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.spec.ts index dad46135ac3..1994935ab9c 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.spec.ts @@ -92,6 +92,15 @@ describe('Client - Platform - Contracts - .get()', () => { expect(contract.toJSON()).to.deep.equal(dataContractFixture.toJSON()); expect(askedFromDapi).to.equal(1); }); + + it('should fetch from DAPIClient when asked to skip the cache', async () => { + const contract = await get.call({ + // @ts-ignore + apps, dpp, client, initialize, logger, fetcher, + }, dataContractFixture.getId(), { skipCache: true }); + expect(contract.toJSON()).to.deep.equal(dataContractFixture.toJSON()); + expect(askedFromDapi).to.equal(2); + }); }); describe('other conditions', () => { diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.ts index 1a08b3d78a0..7a3982dbf53 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/contracts/get.ts @@ -11,20 +11,29 @@ declare type ContractIdentifier = string | Identifier; * * @param {Platform} this - bound instance class * @param {ContractIdentifier} identifier - identifier of the contract to fetch + * @param {Object} [options] + * @param {boolean} [options.skipCache] - fetch the contract the network stores now, + * ignoring an app's cached one, and cache it in its place * @returns contracts */ -export async function get(this: Platform, identifier: ContractIdentifier): Promise { +export async function get( + this: Platform, + identifier: ContractIdentifier, + options: { skipCache?: boolean } = {}, +): Promise { this.logger.debug(`[Contracts#get] Get Data Contract "${identifier}"`); await this.initialize(); const contractId : Identifier = Identifier.from(identifier); // Try to get contract from the cache - // eslint-disable-next-line - for (const appName of this.client.getApps().getNames()) { - const appDefinition = this.client.getApps().get(appName); - if (appDefinition.contractId.equals(contractId) && appDefinition.contract) { - return appDefinition.contract; + if (!options.skipCache) { + // eslint-disable-next-line + for (const appName of this.client.getApps().getNames()) { + const appDefinition = this.client.getApps().get(appName); + if (appDefinition.contractId.equals(contractId) && appDefinition.contract) { + return appDefinition.contract; + } } } diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index d1bb0c79e8e..d8c73978092 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -5,8 +5,31 @@ import generateRandomIdentifier from '@dashevo/wasm-dpp/lib/test/utils/generateR import cryptoModule from 'crypto'; import register from './register'; +import getContract from '../contracts/get'; import { ClientApps } from '../../../ClientApps'; +const sha256d = (data: Buffer): Buffer => { + const sha256 = (bytes: Buffer): Buffer => cryptoModule.createHash('sha256').update(bytes).digest(); + return sha256(sha256(data)); +}; + +// The `domain` schema of DPNS v3: the salt reveals the hash of the writer's id, +// the salt, the normalized label, '.' and the parent domain name +const dpnsV3DomainSchema = () => ({ + properties: { + preorderSalt: { + refersTo: { + findBy: { + saltedDomainHash: { + function: 'sys.hash.sha256d', + params: ['$ownerId', 'preorderSalt', 'normalizedLabel', { const: '.' }, 'parentDomainName'], + }, + }, + }, + }, + }, +}); + describe('Platform', () => { let randomBytesMock; @@ -155,20 +178,7 @@ describe('Platform', () => { const identityId = await generateRandomIdentifier(); identityMock.getId.returns(identityId); platformMock.contracts.get.resolves({ - getDocumentSchema: () => ({ - properties: { - preorderSalt: { - refersTo: { - findBy: { - saltedDomainHash: { - function: 'sys.hash.sha256d', - params: ['$ownerId', 'preorderSalt', 'normalizedLabel', { const: '.' }, 'parentDomainName'], - }, - }, - }, - }, - }, - }), + getDocumentSchema: dpnsV3DomainSchema, }); await register.call(platformMock, 'User.dash', { @@ -176,15 +186,61 @@ describe('Platform', () => { }, identityMock); // sha256d(owner id ++ salt ++ 'user' ++ '.' ++ 'dash') - const sha256 = (data) => cryptoModule.createHash('sha256').update(data).digest(); - const expected = sha256(sha256(Buffer.concat([ + const expected = sha256d(Buffer.concat([ + identityId.toBuffer(), + Buffer.alloc(32), + Buffer.from('user.dash'), + ])); + expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.equal( + expected.toString('hex'), + ); + }); + + it('should hash with the DPNS contract the network stores, not a cached one', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + + // An app cache warmed with DPNS v2, which declares no preorder hash, on a + // network that has since stored DPNS v3 + const { contractId } = platformMock.client.getApps().get('dpns'); + const cachedContract = { + getDocumentSchema: () => ({ properties: { preorderSalt: {} } }), + }; + const storedContract = { + getDocumentSchema: dpnsV3DomainSchema, + }; + const apps = new ClientApps({ + dpns: { contractId, contract: cachedContract }, + }); + platformMock.client.getApps = () => apps; + platformMock.logger = { debug: () => {}, silly: () => {} }; + platformMock.fetcher = { + fetchDataContract: async () => ({ getDataContract: () => new Uint8Array() }), + }; + platformMock.dpp = { + dataContract: { createFromBuffer: async () => storedContract }, + }; + platformMock.contracts.get = (identifier, options) => getContract.call( + platformMock, + identifier, + options, + ); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + // sha256d(owner id ++ salt ++ 'user' ++ '.' ++ 'dash') + const expected = sha256d(Buffer.concat([ identityId.toBuffer(), Buffer.alloc(32), Buffer.from('user.dash'), - ]))); + ])); expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.equal( expected.toString('hex'), ); + // The documents are created against the contract the hash was taken from + expect(apps.get('dpns').contract).to.equal(storedContract); }); it('should fail if DPNS app have no contract set up', async () => { diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 39105243944..52380e8ecd7 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -56,9 +56,12 @@ export async function register( // preorderSalt against. From DPNS v3 the contract declares it: the params of // the salt's findBy function (the writer's id, the salt, the normalized label, // '.' and the parent domain name as sent). Before it, the salt and - // `${normalizedLabel}.${parentDomainName}`. + // `${normalizedLabel}.${parentDomainName}`. The contract is the one the + // network stores now, not one cached before an upgrade changed it, whose hash + // the domain could not reveal; fetching it also replaces the cached one, so + // both documents are created against it. const { contractId } = this.client.getApps().get('dpns'); - const dpnsContract = await this.contracts.get(contractId); + const dpnsContract = await this.contracts.get(contractId, { skipCache: true }); const domainSchema = dpnsContract ? dpnsContract.getDocumentSchema('domain') : undefined; const hashParams = domainSchema?.properties?.preorderSalt?.refersTo ?.findBy?.saltedDomainHash?.params; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 4432b971df3..0aeef7f409a 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -9,7 +9,7 @@ pub use queries::DpnsUsername; use crate::platform::transition::put_document::PutDocument; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::{Document, FetchMany}; +use crate::platform::{DataContract, Document, Fetch, FetchMany}; use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; @@ -63,7 +63,9 @@ fn hash_double(data: Vec) -> [u8; 32] { /// DPNS contract the network runs declares it. From DPNS v3 the domain's /// `preorderSalt` reveals the hash of the writer's id, the salt, the normalized /// label, `"."` and the parent, and the declaration computes it, as the platform -/// does; before it the hash is of the salt and `.dash`. +/// does; before it the salt carries no reference and the hash is of the salt and +/// `.dash`. A reference without a `findBy` function is an error, +/// never the older hash, which such a contract could not reveal. fn salted_domain_hash( domain_document_type: DocumentTypeRef, owner_id: Identifier, @@ -71,30 +73,33 @@ fn salted_domain_hash( normalized_label: &str, domain_properties: &BTreeMap, ) -> Result<[u8; 32], Error> { - let declared_key = domain_document_type + let Some(reference) = domain_document_type .flattened_properties() .get("preorderSalt") .and_then(|property| property.revealed_reference.as_ref()) - .and_then(|target| target.as_any_document_reference()) + else { + let mut salted_domain_buffer = salt.to_vec(); + salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); + return Ok(hash_double(salted_domain_buffer)); + }; + let key = reference + .as_any_document_reference() .and_then(|declaration| declaration.lookup) .and_then(|lookup| lookup.hash_key()) - .map(|(_, key)| key); - match declared_key { - Some(key) => key - .key_value(domain_document_type, None, owner_id, domain_properties) - .map(|(hash, _)| hash) - .map_err(|error| { - Error::Generic(format!( - "cannot compute the DPNS preorder hash: {} {}", - error.param, error.reason - )) - }), - None => { - let mut salted_domain_buffer = salt.to_vec(); - salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); - Ok(hash_double(salted_domain_buffer)) - } - } + .map(|(_, key)| key) + .ok_or_else(|| { + Error::Generic( + "the DPNS domain's preorderSalt reference declares no findBy function".to_string(), + ) + })?; + key.key_value(domain_document_type, None, owner_id, domain_properties) + .map(|(hash, _)| hash) + .map_err(|error| { + Error::Generic(format!( + "cannot compute the DPNS preorder hash: {} {}", + error.param, error.reason + )) + }) } /// Callback type for preorder document @@ -162,6 +167,24 @@ impl Sdk { .ok_or_else(|| Error::Generic("DPNS contract not found".to_string())) } + /// The DPNS contract the network stores now, fetched and proved, for a registration. + /// The preorder commits to the hash that contract declares, so neither a contract the + /// context provider holds for the SDK's protocol version nor one cached before an upgrade + /// changed it will do: either can be older than the network's, and its hash one the + /// domain cannot reveal. The SDK first learns the network's protocol version (a proven + /// refresh), so the contract is parsed under it; when the refresh failed and the fetch's + /// own response teaches the SDK a newer version, that contract was parsed under the older + /// one and is fetched again. + async fn fetch_current_dpns_contract(&self) -> Result { + let dpns_contract_id = self.get_dpns_contract_id()?; + let protocol_version = self.refresh_protocol_version().await?; + let mut dpns_contract = DataContract::fetch(self, dpns_contract_id).await?; + if self.protocol_version_number() != protocol_version { + dpns_contract = DataContract::fetch(self, dpns_contract_id).await?; + } + dpns_contract.ok_or_else(|| Error::Generic("DPNS contract not found".to_string())) + } + /// Register a DPNS username in a single operation /// /// This method handles both the preorder and domain registration steps automatically. @@ -185,7 +208,7 @@ impl Sdk { &self, input: RegisterDpnsNameInput, ) -> Result { - let dpns_contract = self.fetch_dpns_contract().await?; + let dpns_contract = self.fetch_current_dpns_contract().await?; // Get document types let preorder_document_type = dpns_contract @@ -508,6 +531,13 @@ fn identity_from_domain_records( #[cfg(test)] mod tests { use super::*; + use crate::sdk::test::expect_epoch_refresh; + use crate::SdkBuilder; + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dpp::data_contract::TokenConfiguration; + use dpp::prelude::CoreBlockHeight; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::{PlatformVersion, LATEST_VERSION}; #[test] fn test_normalize_dpns_label_strips_dash_suffix_case_insensitively() { @@ -582,6 +612,158 @@ mod tests { } } + const SALT: [u8; 32] = [9u8; 32]; + + /// The properties of a domain `Bob.dash` the preorder hash may read. + fn bob_domain_properties() -> BTreeMap { + BTreeMap::from([ + ("label".to_string(), Value::Text("Bob".to_string())), + ( + "normalizedLabel".to_string(), + Value::Text("b0b".to_string()), + ), + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ("preorderSalt".to_string(), Value::Bytes32(SALT)), + ]) + } + + /// The preorder hash the SDK computes for `owner_id` and `properties` against the DPNS + /// contract of `platform_version`. + fn preorder_hash( + platform_version: &PlatformVersion, + owner_id: Identifier, + properties: &BTreeMap, + ) -> Result<[u8; 32], Error> { + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("the DPNS contract loads"); + let domain = contract + .document_type_for_name("domain") + .expect("the DPNS contract has a domain type"); + salted_domain_hash(domain, owner_id, SALT, "b0b", properties) + } + + fn sha256d(parts: &[&[u8]]) -> [u8; 32] { + use dpp::dashcore::hashes::{sha256d, Hash}; + sha256d::Hash::hash(&parts.concat()).to_byte_array() + } + + #[test] + fn should_hash_the_salt_and_name_whoever_the_owner_for_dpns_v2() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let expected = sha256d(&[&SALT, b"b0b.dash"]); + for owner_id in [Identifier::new([1u8; 32]), Identifier::new([2u8; 32])] { + assert_eq!( + preorder_hash(platform_version, owner_id, &bob_domain_properties()) + .expect("DPNS v2 hashes the salt and the name"), + expected + ); + } + } + + #[test] + fn should_hash_the_owner_salt_and_name_the_dpns_v3_contract_declares() { + let platform_version = PlatformVersion::latest(); + for owner_id in [Identifier::new([1u8; 32]), Identifier::new([2u8; 32])] { + assert_eq!( + preorder_hash(platform_version, owner_id, &bob_domain_properties()) + .expect("DPNS v3 hashes the declared params"), + sha256d(&[owner_id.as_slice(), &SALT, b"b0b", b".", b"dash"]), + ); + } + } + + #[test] + fn should_refuse_a_dpns_v3_domain_missing_a_hashed_property() { + let mut properties = bob_domain_properties(); + properties.remove("normalizedLabel"); + assert!(preorder_hash( + PlatformVersion::latest(), + Identifier::new([1u8; 32]), + &properties + ) + .is_err()); + } + + /// A context provider seeded before the upgrade to DPNS v3: it holds DPNS v2 whatever + /// protocol version it is asked for, as a provider's known contracts do. + struct DpnsV2ContextProvider; + + impl ContextProvider for DpnsV2ContextProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + load_system_data_contract(SystemDataContract::DPNS, platform_version) + .map(|contract| Some(Arc::new(contract))) + .map_err(|error| ContextProviderError::Generic(error.to_string())) + } + + 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> { + Err(ContextProviderError::InvalidQuorum( + "no quorum in this test".to_string(), + )) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + } + + #[tokio::test] + async fn should_register_against_the_dpns_contract_the_network_stores() { + // An SDK seeded at protocol version 13, whose context provider holds DPNS v2, on a + // network that runs the latest version and stores DPNS v3 + let mut sdk = SdkBuilder::new_mock() + .with_initial_version(PlatformVersion::get(13).expect("protocol version 13")) + .with_context_provider(DpnsV2ContextProvider) + .build() + .expect("the mock SDK builds"); + expect_epoch_refresh(&mut sdk).await; + let stored = load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("the DPNS contract loads"); + sdk.mock() + .expect_fetch(stored.id(), Some(stored.clone())) + .await + .expect("the DPNS fetch is expected"); + + let dpns_contract = sdk + .fetch_current_dpns_contract() + .await + .expect("the DPNS contract is fetched"); + + assert_eq!(sdk.protocol_version_number(), LATEST_VERSION); + let domain = dpns_contract + .document_type_for_name("domain") + .expect("the DPNS contract has a domain type"); + let owner_id = Identifier::new([1u8; 32]); + assert_eq!( + salted_domain_hash(domain, owner_id, SALT, "b0b", &bob_domain_properties()) + .expect("the stored contract declares the hash"), + sha256d(&[owner_id.as_slice(), &SALT, b"b0b", b".", b"dash"]), + ); + } + #[test] fn test_extract_dpns_label() { assert_eq!(extract_dpns_label("alice.dash"), "alice"); diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index cd8769b136a..73e3773b5e6 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1371,7 +1371,7 @@ pub fn prettify_proof(proof: &Proof) -> String { } #[cfg(test)] -mod test { +pub(crate) mod test { use std::sync::Arc; use dapi_grpc::platform::v0::{GetIdentityRequest, ResponseMetadata}; @@ -2385,7 +2385,7 @@ mod test { /// metadata, so consuming this expectation drives `refresh_protocol_version` /// through the same verified `maybe_update_protocol_version` ratchet a real /// quorum-signed response would — the exact path production relies on. - async fn expect_epoch_refresh(sdk: &mut super::Sdk) { + pub(crate) async fn expect_epoch_refresh(sdk: &mut super::Sdk) { use crate::platform::types::epoch::EpochQuery; use crate::platform::LimitQuery; use dpp::block::extended_epoch_info::{v0::ExtendedEpochInfoV0, ExtendedEpochInfo}; From fbd02c91b8cbbbd38437dd6e7c22975246e5dd2e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 10:10:26 +0200 Subject: [PATCH 07/62] fix(sdk): take the DPNS contract only from a response proved at the parsed version Comparing the SDK's protocol version before and after the fetch missed a node behind the network: after the refresh learned protocol 14, a node at the block before activation could still prove DPNS v2 at 13, the ratchet ignores the lower version, and the registration would hash the old way. The registration now reads each DPNS contract response with its metadata and takes the first one proved at the version the SDK parsed it under, at most 3 times. A response proved at a newer version was parsed under an older one (decoding DPNS v3 under 13 drops the salt's reference) and has raised the SDK's version, so the next is parsed under it; one proved at an older version is skipped. Otherwise the registration fails before paying. The document building moves into `dpns_registration_documents`, so an offline test drives it against a context provider holding v2 and checks the preorder's owner-bound hash and the domain's salt. Scripted tests cover a response parsed under an older version (rebuilt from serialized bytes as a proof decode does), a node behind the network, and every node behind. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 294 +++++++++++++++--- 1 file changed, 246 insertions(+), 48 deletions(-) diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 0aeef7f409a..f44474c7014 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -11,11 +11,12 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::transition::put_settings::PutSettings; use crate::platform::{DataContract, Document, Fetch, FetchMany}; use crate::{Error, Sdk}; +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; 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::document_type::{DocumentType, DocumentTypeRef}; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; @@ -25,6 +26,7 @@ use dpp::platform_value::Value; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use std::collections::BTreeMap; +use std::future::Future; use std::sync::Arc; use tracing::debug; use tracing::warn; @@ -136,6 +138,53 @@ pub struct RegisterDpnsNameResult { pub full_domain_name: String, } +/// The documents a DPNS registration submits, built against one DPNS contract. +struct DpnsRegistrationDocuments { + preorder_document_type: DocumentType, + preorder_document: Document, + domain_document_type: DocumentType, + domain_document: Document, + normalized_label: String, +} + +/// How many DPNS contract responses a registration reads for one proved at the protocol +/// version the SDK parsed it under. +const DPNS_CONTRACT_FETCH_ATTEMPTS: usize = 3; + +/// The DPNS contract of the first response proved at the protocol version the SDK parsed it +/// under, reading the SDK's version through `sdk_version` and each response through `fetch`, +/// at most [`DPNS_CONTRACT_FETCH_ATTEMPTS`] times. A response proved at a newer version was +/// parsed under an older one, which can drop the declaration the preorder hash follows (the +/// response has taught the SDK the newer version, so the next one is parsed under it). One +/// proved at an older version comes from a node behind the network, which can still hold the +/// contract an upgrade replaced. Either would commit the preorder to a hash the domain cannot +/// reveal, so neither is used. +async fn dpns_contract_proved_at_parsed_version( + sdk_version: V, + mut fetch: F, +) -> Result +where + V: Fn() -> u32, + F: FnMut() -> R, + R: Future, ResponseMetadata), Error>>, +{ + let mut last_mismatch = None; + for _ in 0..DPNS_CONTRACT_FETCH_ATTEMPTS { + let parsed_under = sdk_version(); + let (dpns_contract, metadata) = fetch().await?; + if metadata.protocol_version == parsed_under { + return dpns_contract + .ok_or_else(|| Error::Generic("DPNS contract not found".to_string())); + } + last_mismatch = Some((metadata.protocol_version, parsed_under)); + } + let (proved_at, parsed_under) = last_mismatch.unwrap_or_default(); + Err(Error::Generic(format!( + "no DPNS contract response was proved at the protocol version the SDK parsed it under \ + (last proved at {proved_at}, parsed under {parsed_under})" + ))) +} + impl Sdk { /// Helper method to get the DPNS contract ID fn get_dpns_contract_id(&self) -> Result { @@ -172,42 +221,26 @@ impl Sdk { /// context provider holds for the SDK's protocol version nor one cached before an upgrade /// changed it will do: either can be older than the network's, and its hash one the /// domain cannot reveal. The SDK first learns the network's protocol version (a proven - /// refresh), so the contract is parsed under it; when the refresh failed and the fetch's - /// own response teaches the SDK a newer version, that contract was parsed under the older - /// one and is fetched again. + /// refresh), then takes a response proved at the version it parsed it under + /// ([`dpns_contract_proved_at_parsed_version`]). async fn fetch_current_dpns_contract(&self) -> Result { let dpns_contract_id = self.get_dpns_contract_id()?; - let protocol_version = self.refresh_protocol_version().await?; - let mut dpns_contract = DataContract::fetch(self, dpns_contract_id).await?; - if self.protocol_version_number() != protocol_version { - dpns_contract = DataContract::fetch(self, dpns_contract_id).await?; - } - dpns_contract.ok_or_else(|| Error::Generic("DPNS contract not found".to_string())) + self.refresh_protocol_version().await?; + dpns_contract_proved_at_parsed_version( + || self.protocol_version_number(), + || DataContract::fetch_with_metadata(self, dpns_contract_id, None), + ) + .await } - /// Register a DPNS username in a single operation - /// - /// This method handles both the preorder and domain registration steps automatically. - /// It generates the necessary entropy, creates both documents, and submits them in order. - /// - /// # Arguments - /// - /// * `input` - The registration input containing label, identity, public key, and signer - /// - /// # Returns - /// - /// Returns a `RegisterDpnsNameResult` containing both created documents and the full domain name - /// - /// # Errors - /// - /// Returns an error if: - /// - The DPNS contract cannot be fetched - /// - Document types are not found in the contract - /// - Document creation or submission fails - pub async fn register_dpns_name>( + /// The documents a registration of `label` by `identity_id` submits, built against the + /// DPNS contract the network stores now: the preorder, committing to the hash that + /// contract declares, and the domain revealing it. + async fn dpns_registration_documents( &self, - input: RegisterDpnsNameInput, - ) -> Result { + label: &str, + identity_id: Identifier, + ) -> Result { let dpns_contract = self.fetch_current_dpns_contract().await?; // Get document types @@ -228,11 +261,10 @@ impl Sdk { // fetched that nonce. The documents are built with a placeholder id; // the confirmed documents carry the real one. Nothing here needs the // ids up front: the domain is tied to its preorder by the salt. - let identity_id = input.identity.id().to_owned(); let preorder_id = Identifier::default(); let domain_id = Identifier::default(); - let normalized_label = convert_to_homograph_safe_chars(&input.label); + let normalized_label = convert_to_homograph_safe_chars(label); let domain_properties = BTreeMap::from([ ( "parentDomainName".to_string(), @@ -242,7 +274,7 @@ impl Sdk { "normalizedParentDomainName".to_string(), Value::Text("dash".to_string()), ), - ("label".to_string(), Value::Text(input.label.clone())), + ("label".to_string(), Value::Text(label.to_string())), ( "normalizedLabel".to_string(), Value::Text(normalized_label.clone()), @@ -318,12 +350,55 @@ impl Sdk { moderated_by: None, }); + Ok(DpnsRegistrationDocuments { + preorder_document_type: preorder_document_type.to_owned_document_type(), + preorder_document, + domain_document_type: domain_document_type.to_owned_document_type(), + domain_document, + normalized_label, + }) + } + + /// Register a DPNS username in a single operation + /// + /// This method handles both the preorder and domain registration steps automatically. + /// It generates the necessary entropy, creates both documents, and submits them in order. + /// + /// # Arguments + /// + /// * `input` - The registration input containing label, identity, public key, and signer + /// + /// # Returns + /// + /// Returns a `RegisterDpnsNameResult` containing both created documents and the full domain name + /// + /// # Errors + /// + /// Returns an error if: + /// - The DPNS contract cannot be fetched + /// - Document types are not found in the contract + /// - Document creation or submission fails + pub async fn register_dpns_name>( + &self, + input: RegisterDpnsNameInput, + ) -> Result { + let identity_id = input.identity.id().to_owned(); + let DpnsRegistrationDocuments { + preorder_document_type, + preorder_document, + domain_document_type, + domain_document, + normalized_label, + } = self + .dpns_registration_documents(&input.label, identity_id) + .await?; + // Submit preorder document first debug!(%identity_id, stage = "preorder", "DPNS registration: submitting document"); let platform_preorder_document = preorder_document .put_to_platform_and_wait_for_response( self, - preorder_document_type.to_owned_document_type(), + preorder_document_type, None, // entropy: generated together with the id once the nonce is known input.identity_public_key.clone(), None, // token payment info @@ -353,7 +428,7 @@ impl Sdk { let platform_domain_document = domain_document .put_to_platform_and_wait_for_response( self, - domain_document_type.to_owned_document_type(), + domain_document_type, None, // entropy: generated together with the id once the nonce is known input.identity_public_key, None, // token payment info @@ -536,8 +611,14 @@ mod tests { use dash_context_provider::{ContextProvider, ContextProviderError}; use dpp::data_contract::TokenConfiguration; use dpp::prelude::CoreBlockHeight; + use dpp::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::version::{PlatformVersion, LATEST_VERSION}; + use std::cell::{Cell, RefCell}; + use std::future::ready; #[test] fn test_normalize_dpns_label_strips_dash_suffix_case_insensitively() { @@ -731,7 +812,7 @@ mod tests { } #[tokio::test] - async fn should_register_against_the_dpns_contract_the_network_stores() { + async fn should_build_the_registration_against_the_dpns_contract_the_network_stores() { // An SDK seeded at protocol version 13, whose context provider holds DPNS v2, on a // network that runs the latest version and stores DPNS v3 let mut sdk = SdkBuilder::new_mock() @@ -747,23 +828,140 @@ mod tests { .await .expect("the DPNS fetch is expected"); - let dpns_contract = sdk - .fetch_current_dpns_contract() + let owner_id = Identifier::new([1u8; 32]); + let documents = sdk + .dpns_registration_documents("Bob", owner_id) .await - .expect("the DPNS contract is fetched"); + .expect("the registration documents are built"); assert_eq!(sdk.protocol_version_number(), LATEST_VERSION); - let domain = dpns_contract - .document_type_for_name("domain") - .expect("the DPNS contract has a domain type"); - let owner_id = Identifier::new([1u8; 32]); + let Some(Value::Bytes32(salt)) = documents.domain_document.properties().get("preorderSalt") + else { + panic!("the domain carries a 32-byte preorderSalt"); + }; + assert_eq!(documents.preorder_document.owner_id(), owner_id); assert_eq!( - salted_domain_hash(domain, owner_id, SALT, "b0b", &bob_domain_properties()) - .expect("the stored contract declares the hash"), - sha256d(&[owner_id.as_slice(), &SALT, b"b0b", b".", b"dash"]), + documents + .preorder_document + .properties() + .get("saltedDomainHash"), + Some(&Value::Bytes32(sha256d(&[ + owner_id.as_slice(), + salt, + b"b0b", + b".", + b"dash" + ]))), ); } + /// The DPNS contract of `platform_version`, as a proof decodes it under `parsed_under`: + /// serialized, then rebuilt without validation. + fn dpns_contract_decoded_under( + platform_version: &PlatformVersion, + parsed_under: u32, + ) -> DataContract { + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("the DPNS contract loads"); + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the DPNS contract serializes"); + DataContract::versioned_deserialize_untrusted( + &bytes, + false, + PlatformVersion::get(parsed_under).expect("a known protocol version"), + ) + .expect("the DPNS contract decodes") + } + + fn proved_at(protocol_version: u32) -> ResponseMetadata { + ResponseMetadata { + protocol_version, + ..Default::default() + } + } + + fn declares_the_preorder_hash(dpns_contract: &DataContract) -> bool { + dpns_contract + .document_type_for_name("domain") + .expect("the DPNS contract has a domain type") + .flattened_properties() + .get("preorderSalt") + .is_some_and(|property| property.revealed_reference.is_some()) + } + + #[tokio::test] + async fn should_fetch_again_a_dpns_contract_parsed_under_an_older_protocol_version() { + // The refresh failed: the SDK is still at 13 when the response, proved at the latest + // version, is parsed, and parsing DPNS v3 under 13 drops the salt's reference. The + // response teaches the SDK the latest version, so the next one is parsed under it. + let sdk_version = Cell::new(13); + let fetches = Cell::new(0); + let dpns_contract = dpns_contract_proved_at_parsed_version( + || sdk_version.get(), + || { + fetches.set(fetches.get() + 1); + let dpns_contract = + dpns_contract_decoded_under(PlatformVersion::latest(), sdk_version.get()); + sdk_version.set(LATEST_VERSION); + ready(Ok((Some(dpns_contract), proved_at(LATEST_VERSION)))) + }, + ) + .await + .expect("the second response is taken"); + + assert_eq!(fetches.get(), 2); + assert!(!declares_the_preorder_hash(&dpns_contract_decoded_under( + PlatformVersion::latest(), + 13 + ))); + assert!(declares_the_preorder_hash(&dpns_contract)); + } + + #[tokio::test] + async fn should_skip_a_dpns_contract_proved_by_a_node_behind_the_network() { + // The SDK learned the latest version; the first node is still at 13 and proves DPNS + // v2, the next one proves DPNS v3 + let responses = RefCell::new(vec![ + (PlatformVersion::latest(), LATEST_VERSION), + (PlatformVersion::get(13).expect("protocol version 13"), 13), + ]); + let dpns_contract = dpns_contract_proved_at_parsed_version( + || LATEST_VERSION, + || { + let (stored_at, proved) = responses + .borrow_mut() + .pop() + .expect("a response is scripted"); + let dpns_contract = dpns_contract_decoded_under(stored_at, LATEST_VERSION); + ready(Ok((Some(dpns_contract), proved_at(proved)))) + }, + ) + .await + .expect("the current node's response is taken"); + + assert!(responses.borrow().is_empty()); + assert!(declares_the_preorder_hash(&dpns_contract)); + } + + #[tokio::test] + async fn should_refuse_when_every_dpns_contract_response_is_behind_the_network() { + let fetches = Cell::new(0); + let result = dpns_contract_proved_at_parsed_version( + || LATEST_VERSION, + || { + fetches.set(fetches.get() + 1); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let dpns_contract = dpns_contract_decoded_under(platform_version, LATEST_VERSION); + ready(Ok((Some(dpns_contract), proved_at(13)))) + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!(fetches.get(), DPNS_CONTRACT_FETCH_ATTEMPTS); + } + #[test] fn test_extract_dpns_label() { assert_eq!(extract_dpns_label("alice.dash"), "alice"); From b720af0496b464182d86651753bf41c0fb704395 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 10:23:52 +0200 Subject: [PATCH 08/62] fix(drive): seed the block cache with the contract a rebuild stores `apply_contract_rebuilding_document_types` stored the new contract through `update_contract_add_operations`, which only builds operations, so unlike an update it never put the rewritten contract in the block cache. Until the protocol change events refresh every system contract at their end, a read in the block could fall back to a global cache entry a committed-state read had filled with the old contract. The rebuild now fetches what it stored and seeds it with `insert_rewritten`, as `update_contract` does. Test: DPNS v2 warm in the global cache, the preorder type rebuilt, and a read in the block's transaction gets v3. The book's same-block transfer case says the rule still applies. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 2 +- ...pply_contract_rebuilding_document_types.rs | 85 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 18432c44d24..583c7d677d9 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -196,7 +196,7 @@ A rule can read when the document was created, last updated and last transferred - `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation. - `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after the listing ends. - `{ "lessThanOrEqual": ["$transferredAt", "endsAt"] }` refuses a transfer or a purchase after it ends. -- `{ "ifThen": [{ "equal": ["$transferredAt", "$createdAt"] }, { "equal": ["records.identity", "$ownerId"] }] }` holds `records.identity` to the owner until the document first changes hands. A create records its block's time as the transfer too, so the condition holds on the create, and a transfer or a purchase in a later block records a later time, so it no longer reads the property. One in the block that created the document has that block's time and is judged as the create was. The DPNS `domain` type's `recordsIdentityIsOwner` (protocol version 14) is this rule. +- `{ "ifThen": [{ "equal": ["$transferredAt", "$createdAt"] }, { "equal": ["records.identity", "$ownerId"] }] }` holds `records.identity` to the owner until the document first changes hands. A create records its block's time as the transfer too, so the condition holds on the create, and a transfer or a purchase in a later block records a later time, so it no longer reads the property. One in the block that created the document records that block's time, so the rule still applies to it: a new owner other than `records.identity` is refused. The DPNS `domain` type's `recordsIdentityIsOwner` (protocol version 14) is this rule. A rule may read one only when the document type records it by listing it in `required`, so every stored document holds it. None takes an `ifAbsent` default, `present` and `absent` refuse them, and an [index-only](index-only.md) type reads none. Each write is judged with the values the stored document ends up with: a create with its block's time and heights for all three events; a replace with the stored creation and transfer values and its block's as the update; a price update with its block's as the update; a transfer or a purchase with its block's as the transfer. diff --git a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs index a57de73c32b..34bfd7f7c63 100644 --- a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs +++ b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs @@ -22,7 +22,8 @@ impl Drive { /// For a protocol upgrade that changes a system contract's document type in a way a /// contract update cannot follow, such as replacing a unique index; the documents deleted /// are not refunded. Runs once, on the first block of the protocol version that ships the - /// new contract (DPNS v3's `preorder` at protocol version 14). + /// new contract (DPNS v3's `preorder` at protocol version 14). Like an update, it leaves + /// the rewritten contract in the block cache. pub fn apply_contract_rebuilding_document_types( &self, contract: &DataContract, @@ -100,6 +101,86 @@ impl Drive { drive_operations, &mut vec![], &platform_version.drive, - ) + )?; + + // As an update does, the block cache gets the rewritten contract, so no read in this + // block falls back to the copy the global cache may hold + let rebuilt_contract_fetch_info = self + .fetch_contract_and_add_operations( + contract_id, + Some(&block_info.epoch), + Some(transaction), + &mut vec![], + platform_version, + )? + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "the rebuilt contract was just stored", + )) + })?; + self.cache + .data_contracts + .insert_rewritten(rebuilt_contract_fetch_info, true); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + 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::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + #[test] + fn should_serve_the_rebuilt_contract_to_reads_in_the_block() { + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version_13)); + let dpns_v2 = load_system_data_contract(SystemDataContract::DPNS, platform_version_13) + .expect("DPNS v2 loads"); + drive + .apply_contract( + &dpns_v2, + BlockInfo::default(), + true, + None, + None, + platform_version_13, + ) + .expect("DPNS v2 is stored"); + let dpns_id = dpns_v2.id().to_buffer(); + // A committed-state read puts DPNS v2 in the global cache + drive + .get_contract_with_fetch_info(dpns_id, true, None, platform_version_13) + .expect("DPNS is read") + .expect("DPNS is stored"); + + let dpns_v3 = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS v3 loads"); + let transaction = drive.grove.start_transaction(); + drive + .apply_contract_rebuilding_document_types( + &dpns_v3, + &["preorder"], + &BlockInfo::default(), + None, + &transaction, + platform_version, + ) + .expect("the preorder type is rebuilt"); + + let read_in_block = drive + .get_contract_with_fetch_info(dpns_id, false, Some(&transaction), platform_version) + .expect("DPNS is read") + .expect("DPNS is stored"); + let preorder = read_in_block + .contract + .document_type_for_name("preorder") + .expect("DPNS has a preorder type"); + assert!(preorder.indexes().contains_key("ownerAndSaltedHash")); + assert!(!preorder.indexes().contains_key("saltedHash")); } } From 51403d3a4b82cebf8cfce2018c3072b9ef4e140b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 14:18:24 +0200 Subject: [PATCH 09/62] fix(sdk): skip a DPNS contract response when the SDK version moved meanwhile The version a registration compared a DPNS contract response with was read before the fetch awaited the network. SDK clones share that version, so another request could raise it from 13 to 14 while the fetch waited; a response from a node still at 13 proving DPNS v2 then matched the stale 13 and was taken, and the preorder hashed the old way. A response is now taken only when the SDK's version is unchanged across the fetch as well as equal to the version the node proved it at. The version only rises, so one unchanged across the fetch is the version the response was parsed under. Regression: the first fetch raises the shared version to the latest while returning DPNS v2 proved at 13, which is skipped, and the next response, v3, is taken. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index f44474c7014..29728e29bbb 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -153,12 +153,13 @@ const DPNS_CONTRACT_FETCH_ATTEMPTS: usize = 3; /// The DPNS contract of the first response proved at the protocol version the SDK parsed it /// under, reading the SDK's version through `sdk_version` and each response through `fetch`, -/// at most [`DPNS_CONTRACT_FETCH_ATTEMPTS`] times. A response proved at a newer version was -/// parsed under an older one, which can drop the declaration the preorder hash follows (the -/// response has taught the SDK the newer version, so the next one is parsed under it). One -/// proved at an older version comes from a node behind the network, which can still hold the -/// contract an upgrade replaced. Either would commit the preorder to a hash the domain cannot -/// reveal, so neither is used. +/// at most [`DPNS_CONTRACT_FETCH_ATTEMPTS`] times. The SDK's version only rises, and SDK clones +/// share it, so a version unchanged across the fetch is the one the response was parsed under; +/// one that rose meanwhile (this response, or another request, taught the SDK a newer version) +/// leaves the response unused. A response proved at a newer version was parsed under an older +/// one, which can drop the declaration the preorder hash follows. One proved at an older +/// version comes from a node behind the network, which can still hold the contract an upgrade +/// replaced. Either would commit the preorder to a hash the domain cannot reveal. async fn dpns_contract_proved_at_parsed_version( sdk_version: V, mut fetch: F, @@ -172,7 +173,7 @@ where for _ in 0..DPNS_CONTRACT_FETCH_ATTEMPTS { let parsed_under = sdk_version(); let (dpns_contract, metadata) = fetch().await?; - if metadata.protocol_version == parsed_under { + if metadata.protocol_version == parsed_under && sdk_version() == parsed_under { return dpns_contract .ok_or_else(|| Error::Generic("DPNS contract not found".to_string())); } @@ -944,6 +945,38 @@ mod tests { assert!(declares_the_preorder_hash(&dpns_contract)); } + #[tokio::test] + async fn should_skip_a_dpns_contract_proved_while_another_request_raised_the_version() { + // The SDK is at 13 when the fetch starts. While it waits, another request on a clone + // teaches the shared SDK the latest version, and the response comes from a node still + // at 13 proving DPNS v2: it matches the version the fetch started at, not the one the + // SDK has learned since. + let sdk_version = Cell::new(13); + let fetches = Cell::new(0); + let dpns_contract = dpns_contract_proved_at_parsed_version( + || sdk_version.get(), + || { + fetches.set(fetches.get() + 1); + let response = if fetches.get() == 1 { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let dpns_v2 = dpns_contract_decoded_under(platform_version, 13); + sdk_version.set(LATEST_VERSION); + (Some(dpns_v2), proved_at(13)) + } else { + let dpns_v3 = + dpns_contract_decoded_under(PlatformVersion::latest(), sdk_version.get()); + (Some(dpns_v3), proved_at(LATEST_VERSION)) + }; + ready(Ok(response)) + }, + ) + .await + .expect("the response proved at the learned version is taken"); + + assert_eq!(fetches.get(), 2); + assert!(declares_the_preorder_hash(&dpns_contract)); + } + #[tokio::test] async fn should_refuse_when_every_dpns_contract_response_is_behind_the_network() { let fetches = Cell::new(0); From 1f1a8b0350552f17168d68e3301033377c84b4df Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 14:31:25 +0200 Subject: [PATCH 10/62] feat(contract): write the DPNS v3 byte arrays with the type shorthands Now that `identifier` and `bytes` + `size` are in v5.0-dev (#5355), the DPNS v3 schema uses them: `preorderSalt` and `saltedDomainHash` are `"type": "bytes", "size": 32`, `records.identity` is `"type": "identifier"`. The parsed types are unchanged; the stored contract keeps the schema as written, so the check_tx contract-update fee pin moves (27003089990 to 27003077990). Protocol 13 cannot decode the shorthands, so the SDK test of a response parsed under an older version now uses DPNS v2 as the contract read under 13. Co-Authored-By: Claude Opus 5.5 --- .../schema/v3/dpns-contract-documents.json | 18 +++++------------- .../src/execution/check_tx/v0/mod.rs | 2 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 19 ++++++++++--------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/dpns-contract/schema/v3/dpns-contract-documents.json b/packages/dpns-contract/schema/v3/dpns-contract-documents.json index 8b3906b6868..6b42d5f7ec6 100644 --- a/packages/dpns-contract/schema/v3/dpns-contract-documents.json +++ b/packages/dpns-contract/schema/v3/dpns-contract-documents.json @@ -83,10 +83,8 @@ "description": "Generated: parentDomainName normalized like normalizedLabel" }, "preorderSalt": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, + "type": "bytes", + "size": 32, "position": 4, "description": "Salt of the writer's preorder, made in an earlier block; the create deletes it", "refersTo": { @@ -115,12 +113,8 @@ "type": "object", "properties": { "identity": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, + "type": "identifier", "position": 1, - "contentMediaType": "application/x.dash.dpp.identifier", "description": "Identity the name resolves to; its owner's at creation" } }, @@ -199,10 +193,8 @@ ], "properties": { "saltedDomainHash": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, + "type": "bytes", + "size": 32, "position": 0, "description": "sha256d of owner id, salt, normalized label, '.' and parent domain name" } diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 10ecc0448f9..836c6d46229 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1967,7 +1967,7 @@ mod tests { // the config is version 2, and the larger DashPay v2 schema adds byte-billed // contract bytes; the DPNS v3 preorder type the update copies in has its own // (its per-owner index, `$createdAtBlockHeight` required) - 27003089990 + 27003077990 ); let check_result = platform diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 29728e29bbb..dde317b809d 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -893,17 +893,22 @@ mod tests { #[tokio::test] async fn should_fetch_again_a_dpns_contract_parsed_under_an_older_protocol_version() { - // The refresh failed: the SDK is still at 13 when the response, proved at the latest - // version, is parsed, and parsing DPNS v3 under 13 drops the salt's reference. The - // response teaches the SDK the latest version, so the next one is parsed under it. + // The refresh failed: the SDK is still at 13 when the first response, proved at the + // latest version, is parsed, so what it holds is read under the older version (here + // DPNS v2, which declares no preorder hash). The response teaches the SDK the latest + // version, so the next one is parsed under it. let sdk_version = Cell::new(13); let fetches = Cell::new(0); let dpns_contract = dpns_contract_proved_at_parsed_version( || sdk_version.get(), || { fetches.set(fetches.get() + 1); - let dpns_contract = - dpns_contract_decoded_under(PlatformVersion::latest(), sdk_version.get()); + let stored_at = if sdk_version.get() == 13 { + PlatformVersion::get(13).expect("protocol version 13") + } else { + PlatformVersion::latest() + }; + let dpns_contract = dpns_contract_decoded_under(stored_at, sdk_version.get()); sdk_version.set(LATEST_VERSION); ready(Ok((Some(dpns_contract), proved_at(LATEST_VERSION)))) }, @@ -912,10 +917,6 @@ mod tests { .expect("the second response is taken"); assert_eq!(fetches.get(), 2); - assert!(!declares_the_preorder_hash(&dpns_contract_decoded_under( - PlatformVersion::latest(), - 13 - ))); assert!(declares_the_preorder_hash(&dpns_contract)); } From 45309b9466125e909272478d362a565b4027287c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 14:47:17 +0200 Subject: [PATCH 11/62] fix(platform): DPNS v3 review fixes Clients: - js-dash-sdk refuses a records.identity other than the registering identity before paying for the preorder when the contract declares recordsIdentityIsOwner; the legacy (pre-v3) hash of a top-level name is of the salt and the label as sent, as trigger v1 hashes it; the "no DPNS app" spec now reaches the code it names. - rs-sdk `salted_domain_hash` reads the salt and label from the domain's properties instead of separate parameters. Drive and drive-abci: - the preorder rebuild refuses a type with trees outside its own subtree (indexOnly, contested index, moderator deletions, ttl), deletes without the backward-reference scan Drive never needs, and drops the always-None storage flags parameter. - trigger v2 queries the parent under the top level directly (a parent domain name holds no dot) and gains in-module tests. Parser and docs: - the consume check moves into `writer_owns_found_document`, so the parse_reference_target match is no longer reflowed. - `LOOKUP_KEY_WRITER` gives way to the existing `OWNER_ID`; the unused `preorder::properties::SALTED_DOMAIN_HASH` and `hash_double` import go. - meta-schema v3, reference_lookup.rs, wasm-dpp2 and the book describe the findBy `$ownerId` entry (40120 for another identity's commitment), the per-owner preorder index and the legacy top-level hash. Tests: - the strategy tests hash preorders with the shared `dpns_salted_domain_hash` (now outside cfg(test)) instead of the rule under test, and lose the stale "disable document triggers" comments; dead `dpns < 3` arms, the pasted height-1 comments and the derivable flag go; the protocol 13 test is renamed `should_...`; preorders are read by id; the award test uses `fast_forward_to_block`; the upgrade layout reference is a bare genesis state. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/deletion.md | 6 +- .../src/contract-keywords/refers-to-lookup.md | 2 +- book/src/data-model/documents.md | 4 +- packages/dpns-contract/src/v1/mod.rs | 4 - .../Platform/methods/names/register.spec.ts | 44 ++- .../Client/Platform/methods/names/register.ts | 13 +- .../document/v3/document-meta.json | 4 +- .../class_methods/try_from_schema/mod.rs | 280 +++++++++--------- .../document_type/property/lookup_preimage.rs | 9 +- .../property/reference_lookup.rs | 14 +- .../v0/mod.rs | 18 +- .../data_triggers/triggers/dpns/v2/mod.rs | 147 ++++++++- .../batch/tests/document/creation.rs | 63 ++-- .../batch/tests/document/dpns.rs | 8 +- .../batch/tests/document/dpns_v3.rs | 77 +++-- .../rs-drive-abci/src/test/helpers/mod.rs | 1 - .../strategy_tests/test_cases/voting_tests.rs | 110 +++---- ...pply_contract_rebuilding_document_types.rs | 45 ++- .../system_data_contract_versions/v3.rs | 5 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 21 +- .../data_contract/document_type_reference.rs | 6 +- 21 files changed, 523 insertions(+), 358 deletions(-) diff --git a/book/src/contract-keywords/deletion.md b/book/src/contract-keywords/deletion.md index 6be3c1a037a..6863e4c4789 100644 --- a/book/src/contract-keywords/deletion.md +++ b/book/src/contract-keywords/deletion.md @@ -66,7 +66,11 @@ A commenter may take a comment down at any time. Since `true` is the usual defau "documentsMutable": false, "canBeDeleted": "onlyWhenConsumed", "indices": [ - { "name": "saltedHash", "properties": [{ "saltedDomainHash": "asc" }], "unique": true } + { + "name": "ownerAndSaltedHash", + "properties": [{ "$ownerId": "asc" }, { "saltedDomainHash": "asc" }], + "unique": true + } ], "properties": { "saltedDomainHash": { diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index f7cbc4e2533..df5441b5904 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -163,7 +163,7 @@ Hashing the writer matters as much as keying by it. A copy of a pending preorder - **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. - **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference that makes the writer the commitment's owner, by the `where` entry `"$ownerId": "$ownerId"` or the `findBy` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). -A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. +A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127) with the owner entry in `where` and 40120 with it in `findBy` (as in DPNS, whose key then finds none of the writer's), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. ## Rules at registration diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 6e67d0eb32d..fb38c552b6a 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -556,7 +556,7 @@ One `findBy` entry may hold a function instead of a source: `" { }; // The `domain` schema of DPNS v3: the salt reveals the hash of the writer's id, -// the salt, the normalized label, '.' and the parent domain name +// the salt, the normalized label, '.' and the parent domain name, and a new +// name's records.identity is its owner const dpnsV3DomainSchema = () => ({ + propertyConstraints: { + recordsIdentityIsOwner: { + ifThen: [ + { equal: ['$transferredAt', '$createdAt'] }, + { equal: ['records.identity', '$ownerId'] }, + ], + }, + }, properties: { preorderSalt: { refersTo: { @@ -85,8 +94,9 @@ describe('Platform', () => { expect(platformMock.documents.create.getCall(0).args[0]).to.deep.equal('dpns.preorder'); expect(platformMock.documents.create.getCall(0).args[1]).to.deep.equal(identityMock); + // sha256d(salt ++ 'Dash'): a top-level name hashes its label as sent expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.deep.equal( - 'dad5808c709bec62b65e607b38846d4fe4080b251cb917a2221c81fb7c3b5f5e', + 'f6e0b46fa0f455f0b8feabdd25d31e65ea5dbb5f6a9bf34d339099917cf3951b', ); expect(platformMock.documents.create.getCall(1).args).to.have.deep.members([ @@ -243,16 +253,40 @@ describe('Platform', () => { expect(apps.get('dpns').contract).to.equal(storedContract); }); - it('should fail if DPNS app have no contract set up', async () => { - delete platformMock.client.getApps().get('dpns').contractId; + it('should refuse records.identity other than the registering identity under DPNS v3', async () => { + identityMock.getId.returns(await generateRandomIdentifier()); + platformMock.contracts.get.resolves({ + getDocumentSchema: dpnsV3DomainSchema, + }); + let error; + try { + await register.call(platformMock, 'User.dash', { + identity: await generateRandomIdentifier(), + }, identityMock); + } catch (e: any) { + error = e; + } + + expect(error.message).to.equal('records.identity must be the identity registering the name.'); + expect(platformMock.documents.create.called).to.equal(false); + expect(platformMock.documents.broadcast.called).to.equal(false); + }); + + it('should fail if DPNS app is not set up', async () => { + platformMock.client.getApps = () => new ClientApps({}); + + let error; try { await register.call(platformMock, 'user.dash', { identity: await generateRandomIdentifier(), }, identityMock); } catch (e: any) { - expect(e.message).to.equal('DPNS is required to register a new name.'); + error = e; } + + expect(error.message).to.equal('DPNS is required to register a new name.'); + expect(platformMock.documents.create.called).to.equal(false); }); }); }); diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 52380e8ecd7..a6d4a4a5456 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -56,7 +56,8 @@ export async function register( // preorderSalt against. From DPNS v3 the contract declares it: the params of // the salt's findBy function (the writer's id, the salt, the normalized label, // '.' and the parent domain name as sent). Before it, the salt and - // `${normalizedLabel}.${parentDomainName}`. The contract is the one the + // `${normalizedLabel}.${parentDomainName}`, or the label as sent for a + // top-level name. The contract is the one the // network stores now, not one cached before an upgrade changed it, whose hash // the domain could not reveal; fetching it also replaces the cached one, so // both documents are created against it. @@ -66,6 +67,14 @@ export async function register( const hashParams = domainSchema?.properties?.preorderSalt?.refersTo ?.findBy?.saltedDomainHash?.params; + // From DPNS v3 a new name's records.identity must be its owner + // (recordsIdentityIsOwner): refuse another before the preorder is paid + if (domainSchema?.propertyConstraints?.recordsIdentityIsOwner + && records.identity + && !Identifier.from(records.identity).equals(identity.getId())) { + throw new Error('records.identity must be the identity registering the name.'); + } + const paramBytes = (param: any): Buffer => { if (typeof param === 'object' && param !== null) { return Buffer.from(param.const); @@ -93,7 +102,7 @@ export async function register( ? Buffer.concat(hashParams.map(paramBytes)) : Buffer.concat([ preorderSalt, - Buffer.from(`${normalizedLabel}.${parentDomainName}`), + Buffer.from(isSecondLevelDomain ? `${normalizedLabel}.${parentDomainName}` : label), ]), ); 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 753528ab8e3..f280e99f3c2 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 @@ -766,7 +766,7 @@ "additionalProperties": false }, "findBy": { - "description": "permanentDocument and deletableDocument references only: how the referenced document is found when the property's value is not its id. Each entry maps a property of the referenced document type (by its name there, system ones such as $ownerId included) to where its value comes from: \".\", the value of the property carrying the reference (on an array element, the element; the writer for ownerRefersTo, the creator for creatorRefersTo), which must be read exactly once; \"$ownerId\", the referring document's owner; a property path of the referring document type; or a function computing it (findByFunction). The entries must name exactly the properties of one unique index of the referenced document type, in any order, that carries no timeRange or integerRange on its first property and does not belong to an indexOnly type: that index finds the document, at most one. Its key must stay with the document it found (every schema property it names fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles. A property an entry reads must be required (and so must every object around it), not transient nor inside a transient object, and hold the same kind of value as the property it fills, so findBy never runs with a missing part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries that index for the key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; where is checked against the document found. A replace re-validates a permanentDocument found by findBy when a property an entry reads changed, and on every replace when an entry reads $ownerId. On a deletableDocument reference findBy says less: once the document it found is deleted the same key may find another filed later, so the reference means a document with this key exists now; every replace re-validates it, as it does a deletableDocument reference by id, and an immutable property may not hold one, except one found by a function, judged when the document is created only, as below. Without findBy the value is the referenced document's $id; { \"$id\": } is the one entry naming $id, with inList: it finds the document holding the list by the id the property holds. A function entry, { referenced property: { \"function\": \"sys.hash.sha256d\", \"params\": [...] } }, fills a byte array property of exactly 32 bytes with the hash of the params' bytes joined in order, for a commit and reveal: the document such a key finds is a commitment made earlier, and the reference is checked when the referring document is created only, never on a replace: every stored property it reads (the function's params, the other entries, the property carrying the reference, the where values) must be fixed once written: a property carrying it or read by it is set when the document is created and never changes, so one listed under immutable with a condition is refused. A param may be transient, being read from the create, and optional, a create missing a param being refused (DocumentReferencePreimageInvalidError, 10423), as is one whose param sits behind a key repeated in an object on the way. So the joined bytes split into the params one way only, every variable-length param (a string, or a byte array without a fixed size) with another variable-length param anywhere after it must be followed directly by a one-byte const, its separator, which its value may not hold (refused the same way). At most one entry is a function; the value carrying the reference is read exactly once, as \".\" or a param of the function, which names a single property carrying the reference by its path and uses \".\" only for a value without one (each element of a typed array, the writer, the creator), except beside a function of an ownerRefersTo or creatorRefersTo, which may leave it out. A string or byte array property may carry a refersTo only this way, its value read by the function: it is not an id. Beside a function the refersTo may declare minimumAgeBlocks and consume. A where entry { \"$ownerId\": \"$ownerId\" } demands that the commitment be the writer's own (ReferencedDocumentPropertyMismatchError, 40127, otherwise); without it anyone who learns the params may reveal them. Available from protocol version 14.", + "description": "permanentDocument and deletableDocument references only: how the referenced document is found when the property's value is not its id. Each entry maps a property of the referenced document type (by its name there, system ones such as $ownerId included) to where its value comes from: \".\", the value of the property carrying the reference (on an array element, the element; the writer for ownerRefersTo, the creator for creatorRefersTo), which must be read exactly once; \"$ownerId\", the referring document's owner; a property path of the referring document type; or a function computing it (findByFunction). The entries must name exactly the properties of one unique index of the referenced document type, in any order, that carries no timeRange or integerRange on its first property and does not belong to an indexOnly type: that index finds the document, at most one. Its key must stay with the document it found (every schema property it names fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles. A property an entry reads must be required (and so must every object around it), not transient nor inside a transient object, and hold the same kind of value as the property it fills, so findBy never runs with a missing part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries that index for the key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; where is checked against the document found. A replace re-validates a permanentDocument found by findBy when a property an entry reads changed, and on every replace when an entry reads $ownerId. On a deletableDocument reference findBy says less: once the document it found is deleted the same key may find another filed later, so the reference means a document with this key exists now; every replace re-validates it, as it does a deletableDocument reference by id, and an immutable property may not hold one, except one found by a function, judged when the document is created only, as below. Without findBy the value is the referenced document's $id; { \"$id\": } is the one entry naming $id, with inList: it finds the document holding the list by the id the property holds. A function entry, { referenced property: { \"function\": \"sys.hash.sha256d\", \"params\": [...] } }, fills a byte array property of exactly 32 bytes with the hash of the params' bytes joined in order, for a commit and reveal: the document such a key finds is a commitment made earlier, and the reference is checked when the referring document is created only, never on a replace: every stored property it reads (the function's params, the other entries, the property carrying the reference, the where values) must be fixed once written: a property carrying it or read by it is set when the document is created and never changes, so one listed under immutable with a condition is refused. A param may be transient, being read from the create, and optional, a create missing a param being refused (DocumentReferencePreimageInvalidError, 10423), as is one whose param sits behind a key repeated in an object on the way. So the joined bytes split into the params one way only, every variable-length param (a string, or a byte array without a fixed size) with another variable-length param anywhere after it must be followed directly by a one-byte const, its separator, which its value may not hold (refused the same way). At most one entry is a function; the value carrying the reference is read exactly once, as \".\" or a param of the function, which names a single property carrying the reference by its path and uses \".\" only for a value without one (each element of a typed array, the writer, the creator), except beside a function of an ownerRefersTo or creatorRefersTo, which may leave it out. A string or byte array property may carry a refersTo only this way, its value read by the function: it is not an id. Beside a function the refersTo may declare minimumAgeBlocks and consume. A where entry { \"$ownerId\": \"$ownerId\" } demands that the commitment be the writer's own (ReferencedDocumentPropertyMismatchError, 40127, otherwise), and a findBy entry { \"$ownerId\": \"$ownerId\" } finds only the writer's own (ReferencedEntityNotFoundError, 40120, otherwise); beside a function that findBy entry may read the writer on a type whose documents change owner, its key being judged when the document is created only. Without either, anyone who learns the params may reveal them. Available from protocol version 14.", "type": "object", "minProperties": 1, "maxProperties": 10, @@ -838,7 +838,7 @@ "maximum": 4294967295 }, "consume": { - "description": "Beside a findBy function, only: the create deletes the document findBy finds, in the same state transition, its storage refunded to its owner as a delete by that owner would. Only on a deletableDocument reference whose where compares \"$ownerId\" with \"$ownerId\", so a create consumes the writer's own commitment and no other, into a document type of the declaring contract whose documents their owner may delete (canBeDeleted: true) or that only a consume removes (canBeDeleted: \"onlyWhenConsumed\"), that declares no delete token cost and no delete action fee (no delete transition charges them) and requires no stricter signature security level than the declaring type, checked at contract registration. A key bound to a contract or a document type must be allowed to act on every document type the create may consume (ContractBoundedKeyOutOfBoundsError, 20014). A batch that consumes the same document twice, or also deletes, replaces, transfers, reprices or buys it, refuses the create that would consume it (ReferencedEntityNotFoundError, 40120). Available from protocol version 14.", + "description": "Beside a findBy function, only: the create deletes the document findBy finds, in the same state transition, its storage refunded to its owner as a delete by that owner would. Only on a deletableDocument reference whose where or findBy maps \"$ownerId\" to \"$ownerId\", so a create consumes the writer's own commitment and no other, into a document type of the declaring contract whose documents their owner may delete (canBeDeleted: true) or that only a consume removes (canBeDeleted: \"onlyWhenConsumed\"), that declares no delete token cost and no delete action fee (no delete transition charges them) and requires no stricter signature security level than the declaring type, checked at contract registration. A key bound to a contract or a document type must be allowed to act on every document type the create may consume (ContractBoundedKeyOutOfBoundsError, 20014). A batch that consumes the same document twice, or also deletes, replaces, transfers, reprices or buys it, refuses the create that would consume it (ReferencedEntityNotFoundError, 40120). Available from protocol version 14.", "const": true } }, 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 d087b8cb7c0..3409f859862 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 @@ -1241,169 +1241,165 @@ fn parse_reference_target( refers_to_map: &BTreeMap, reference_type: &str, ) -> Result { - let target = - match reference_type { - "identity" => DocumentPropertyReferenceTarget::Identity, - "contract" => DocumentPropertyReferenceTarget::Contract { - contract_requirements: parse_contract_reference_requirements(refers_to_map)?, - }, - "token" => DocumentPropertyReferenceTarget::Token, - // A moderated document is found by the value as its id alone (the keys - // refused `findBy` and `inList`), with the declaration shape the other - // two share; whether the referenced document type leaves its documents - // only to a moderator's recorded removal is checked against state at - // contract registration - // - // A writer gate (a `where` entry valued `"$ownerId"`) is asked about on - // every replace, and once a moderator removes the document only its owner - // and its id are sure to be in its removal record (the fields a type keeps, - // `moderatorAbilities.deleteKeepsFields`, are left out where the document - // held none, and the type may be another contract's): a gate on any other - // property could refuse every later replace, for good on a property that - // can not be repointed or cleared, so it may compare only those two - "moderatedDocument" => { - let (contract_id, document_type_name) = - parse_document_reference_type(refers_to_map, reference_type)?; - let property_agreement = parse_where(refers_to_map, reference_type)?; - if let Some(referenced_property) = property_agreement.iter().find_map( - |(referring_property, referenced_property)| { + let target = match reference_type { + "identity" => DocumentPropertyReferenceTarget::Identity, + "contract" => DocumentPropertyReferenceTarget::Contract { + contract_requirements: parse_contract_reference_requirements(refers_to_map)?, + }, + "token" => DocumentPropertyReferenceTarget::Token, + // A moderated document is found by the value as its id alone (the keys + // refused `findBy` and `inList`), with the declaration shape the other + // two share; whether the referenced document type leaves its documents + // only to a moderator's recorded removal is checked against state at + // contract registration + // + // A writer gate (a `where` entry valued `"$ownerId"`) is asked about on + // every replace, and once a moderator removes the document only its owner + // and its id are sure to be in its removal record (the fields a type keeps, + // `moderatorAbilities.deleteKeepsFields`, are left out where the document + // held none, and the type may be another contract's): a gate on any other + // property could refuse every later replace, for good on a property that + // can not be repointed or cleared, so it may compare only those two + "moderatedDocument" => { + let (contract_id, document_type_name) = + parse_document_reference_type(refers_to_map, reference_type)?; + let property_agreement = parse_where(refers_to_map, reference_type)?; + if let Some(referenced_property) = + property_agreement + .iter() + .find_map(|(referring_property, referenced_property)| { (is_referring_system_agreement_property(referring_property) && !matches!(referenced_property.as_str(), OWNER_ID | ID)) .then_some(referenced_property) - }, - ) { - return Err(DataContractError::InvalidContractStructure(format!( - "moderatedDocument refersTo where compares the writer with \ + }) + { + return Err(DataContractError::InvalidContractStructure(format!( + "moderatedDocument refersTo where compares the writer with \ \"{referenced_property}\": a writer is checked on every replace, and \ once a moderator removes the document only its $ownerId and $id are sure \ to be in its removal record, so the writer may be compared with those \ alone" - ))); - } - DocumentPropertyReferenceTarget::ModeratedDocument { - contract_id, - document_type_name, - property_agreement, - } + ))); } - // The two document targets share one declaration shape; they differ in - // whether the referenced document type must forbid deletion, checked - // against state at contract registration. The document is found by the - // value as its id, by `findBy` through a unique index, or, for a list - // the value must be in (`inList`, permanent only), by a `findBy` naming - // its `$id` from another property - document_target @ ("permanentDocument" | "deletableDocument") => { - let (contract_id, document_type_name) = - parse_document_reference_type(refers_to_map, document_target)?; - - let property_agreement = parse_where(refers_to_map, document_target)?; - let find_by = refers_to_map.get(property_names::FIND_BY); - - if refers_to_map.contains_key(property_names::IN_LIST) { - let Some(find_by) = find_by else { - return Err(DataContractError::InvalidContractStructure( + DocumentPropertyReferenceTarget::ModeratedDocument { + contract_id, + document_type_name, + property_agreement, + } + } + // The two document targets share one declaration shape; they differ in + // whether the referenced document type must forbid deletion, checked + // against state at contract registration. The document is found by the + // value as its id, by `findBy` through a unique index, or, for a list + // the value must be in (`inList`, permanent only), by a `findBy` naming + // its `$id` from another property + document_target @ ("permanentDocument" | "deletableDocument") => { + let (contract_id, document_type_name) = + parse_document_reference_type(refers_to_map, document_target)?; + + let property_agreement = parse_where(refers_to_map, document_target)?; + let find_by = refers_to_map.get(property_names::FIND_BY); + + if refers_to_map.contains_key(property_names::IN_LIST) { + let Some(find_by) = find_by else { + return Err(DataContractError::InvalidContractStructure( "permanentDocument refersTo inList needs findBy { \"$id\": }: the value is an element of the list, not \ the id of the document holding it" .to_string(), )); - }; - return Ok(DocumentPropertyReferenceTarget::ListElement( - parse_list_element_reference( - refers_to_map, - find_by, - contract_id, - document_type_name, - property_agreement, - )?, - )); - } - - // A lookup is its own variant, so an id reference keeps its shape - // (and its encoding in the reference errors) - let Some(find_by) = find_by else { - return Ok(match document_target { - "permanentDocument" => DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - property_agreement, - }, - _ => DocumentPropertyReferenceTarget::DeletableDocument { - contract_id, - document_type_name, - property_agreement, - }, - }); }; - let lookup = parse_find_by(refers_to_map, find_by)?; - match document_target { - "permanentDocument" => { - // A permanent document is never deleted, so there is - // nothing to consume - if lookup.consume { - return Err(DataContractError::InvalidContractStructure( - "permanentDocument refersTo cannot consume the document findBy \ + return Ok(DocumentPropertyReferenceTarget::ListElement( + parse_list_element_reference( + refers_to_map, + find_by, + contract_id, + document_type_name, + property_agreement, + )?, + )); + } + + // A lookup is its own variant, so an id reference keeps its shape + // (and its encoding in the reference errors) + let Some(find_by) = find_by else { + return Ok(match document_target { + "permanentDocument" => DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + property_agreement, + }, + _ => DocumentPropertyReferenceTarget::DeletableDocument { + contract_id, + document_type_name, + property_agreement, + }, + }); + }; + let lookup = parse_find_by(refers_to_map, find_by)?; + match document_target { + "permanentDocument" => { + // A permanent document is never deleted, so there is + // nothing to consume + if lookup.consume { + return Err(DataContractError::InvalidContractStructure( + "permanentDocument refersTo cannot consume the document findBy \ finds, which is never deleted: consume needs a deletableDocument \ reference" - .to_string(), - )); - } - DocumentPropertyReferenceTarget::PermanentDocumentLookup { - contract_id, - document_type_name, - property_agreement, - lookup, - } + .to_string(), + )); + } + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, } - _ => { - // Consuming deletes the found document, so only its - // owner's own reveal may: a `$ownerId` entry of `where`, or of - // `findBy` keying the found document's owner by the writer, - // makes the writer the found document's owner - let owner_is_writer = property_agreement.get(OWNER_ID).map(String::as_str) - == Some(OWNER_ID) - || matches!(lookup.keys.get(OWNER_ID), Some(LookupKeySource::OwnerId)); - if lookup.consume && !owner_is_writer { - return Err(DataContractError::InvalidContractStructure( - "deletableDocument refersTo may consume the document findBy finds \ + } + _ => { + // Consuming deletes the found document, so only its + // owner's own reveal may + if lookup.consume && !writer_owns_found_document(&property_agreement, &lookup) { + return Err(DataContractError::InvalidContractStructure( + "deletableDocument refersTo may consume the document findBy finds \ only when the writer owns it: declare the where entry \ \"$ownerId\": \"$ownerId\", or the findBy entry \ \"$ownerId\": \"$ownerId\"" - .to_string(), - )); - } - DocumentPropertyReferenceTarget::DeletableDocumentLookup { - contract_id, - document_type_name, - property_agreement, - lookup, - } + .to_string(), + )); + } + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, } } } - "identityPublicKey" => { - let key_id_property = refers_to_map - .get_str(property_names::KEY_ID_PROPERTY) - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - - if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { - return Err(DataContractError::InvalidContractStructure(format!( - "identityPublicKey refersTo keyIdProperty must be between 1 and \ - {MAX_PROPERTY_PATH_LENGTH} characters" - ))); - } + } + "identityPublicKey" => { + let key_id_property = refers_to_map + .get_str(property_names::KEY_ID_PROPERTY) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - DocumentPropertyReferenceTarget::IdentityPublicKey { - key_id_property: key_id_property.to_string(), - key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, - } - } - other => { + if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { return Err(DataContractError::InvalidContractStructure(format!( - "invalid refersTo type {other}" - ))) + "identityPublicKey refersTo keyIdProperty must be between 1 and \ + {MAX_PROPERTY_PATH_LENGTH} characters" + ))); } - }; + + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: key_id_property.to_string(), + key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, + } + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "invalid refersTo type {other}" + ))) + } + }; Ok(target) } @@ -1881,6 +1877,16 @@ pub(super) fn parse_doctype_reference( Ok(Some(target)) } +/// Whether a lookup reference makes the writer the found document's owner: a `$ownerId` +/// entry of `where`, or of `findBy` keying the found document's owner by the writer. +fn writer_owns_found_document( + property_agreement: &BTreeMap, + lookup: &DocumentReferenceLookup, +) -> bool { + property_agreement.get(OWNER_ID).map(String::as_str) == Some(OWNER_ID) + || matches!(lookup.keys.get(OWNER_ID), Some(LookupKeySource::OwnerId)) +} + /// The `findBy` of a document reference, `find_by_value`: properties of the /// referenced document type mapped to where each value comes from on the /// referring side (`"."`, `"$ownerId"`, a property path, or a function that diff --git a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs index 236355a606e..5164f86ad19 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs @@ -53,6 +53,7 @@ use crate::data_contract::document_type::property::{ }; use crate::data_contract::document_type::DocumentTypeRef; use crate::data_contract::errors::DataContractError; +use crate::document::property_names::OWNER_ID; use bincode::{Decode, DecodeUntrusted, Encode}; use platform_value::{Identifier, Value}; use serde::ser::SerializeMap; @@ -68,8 +69,6 @@ pub const LOOKUP_KEY_PARAMS: &str = "params"; pub const LOOKUP_KEY_CONST: &str = "const"; /// The param naming the value carrying the reference. pub const LOOKUP_KEY_REFERENCE_VALUE: &str = "."; -/// The param naming the writer, the document's owner when it is created. -pub const LOOKUP_KEY_WRITER: &str = "$ownerId"; /// The most params a computed key may list, the bound meta-schema v3 puts on /// it. @@ -111,7 +110,7 @@ impl Serialize for LookupKeyParam { map.serialize_entry(LOOKUP_KEY_CONST, text)?; map.end() } - LookupKeyParam::Writer => serializer.serialize_str(LOOKUP_KEY_WRITER), + LookupKeyParam::Writer => serializer.serialize_str(OWNER_ID), } } } @@ -405,7 +404,7 @@ fn parse_param(value: &Value) -> Result { if path == LOOKUP_KEY_REFERENCE_VALUE { return Ok(LookupKeyParam::ReferenceValue); } - if path == LOOKUP_KEY_WRITER { + if path == OWNER_ID { return Ok(LookupKeyParam::Writer); } if path.is_empty() || path.len() > MAX_LOOKUP_KEY_PATH_LENGTH || path.starts_with('$') { @@ -454,7 +453,7 @@ fn param_name(param: &LookupKeyParam) -> &str { LookupKeyParam::ReferenceValue => LOOKUP_KEY_REFERENCE_VALUE, LookupKeyParam::Property(path) => path, LookupKeyParam::Const(text) => text, - LookupKeyParam::Writer => LOOKUP_KEY_WRITER, + LookupKeyParam::Writer => OWNER_ID, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index aaef5c93c30..aeef4d62fbc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -161,11 +161,10 @@ pub struct DocumentReferenceLookup { pub minimum_age_blocks: Option, /// With a computed key only: whether the create deletes the document the /// key found (the reference's `consume`, beside `findBy`), in the same - /// state transition. Only a `deletableDocument` reference whose `where` - /// pairs the found document's `$ownerId` with the writer's may consume, - /// into a - /// document type of the declaring contract whose documents their owner - /// may delete. + /// state transition. Only a `deletableDocument` reference whose `where` or + /// `findBy` pairs the found document's `$ownerId` with the writer's may + /// consume, into a document type of the declaring contract whose documents + /// their owner may delete. #[serde(skip_serializing_if = "std::ops::Not::not")] pub consume: bool, } @@ -329,8 +328,9 @@ impl DocumentReferenceLookup { /// assemble the same key from the stored document; it may not be the /// reference property itself, whose value is `"."`. A `"$ownerId"` source /// needs a declaring type whose documents cannot be transferred or - /// traded: the reference is validated when the document is written, and - /// a transfer or a purchase would move the writer part of the key + /// traded, unless the lookup holds a computed key (judged on the create + /// alone, below): the reference is validated when the document is written, + /// and a transfer or a purchase would move the writer part of the key /// without any write, leaving a validated reference that finds nothing. /// With that, every part of the referring side's key changes only through /// a replace, which re-validates the reference. diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index b85218faf36..fd45384fd2a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -705,9 +705,10 @@ impl Platform { /// `profile` document type (DIP-33), the withdrawals contract whose v2 /// schema admits the terminal FAILED value of the `status` property, the /// DPNS contract whose v3 schema checks a domain create with keywords - /// instead of most of its data trigger, and register the app-connect contract that carries the wallet-to-app login - /// handshake and the moderation charters contract that elected moderation - /// teams apply through. + /// instead of most of its data trigger, and register the app-connect + /// contract that carries the wallet-to-app login handshake and the + /// moderation charters contract that elected moderation teams apply + /// through. fn transition_to_version_14( &self, block_info: &BlockInfo, @@ -741,20 +742,15 @@ impl Platform { platform_version, )?; - // DPNS contract v3: a domain create generates its normalized names, reveals and - // deletes the writer's own preorder from an earlier block, and holds its identity - // record to its owner, all by schema keywords; the create data trigger keeps only - // the parent domain checks. Its preorders are unique per owner, which no contract - // update can turn a unique index into, so the `preorder` type is rebuilt: every - // preorder made before this block is deleted, unrefunded, and the type laid out as - // a fresh chain registering DPNS v3 lays it out. + // DPNS contract v3 (v14 note 90): its preorders are unique per owner, which no + // contract update can turn a unique index into, so the `preorder` type is rebuilt + // and every preorder made before this block is deleted, unrefunded. let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; self.drive.apply_contract_rebuilding_document_types( &dpns_contract, &[preorder::NAME], block_info, - None, transaction, platform_version, )?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs index 48394f21de6..21205c65d12 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs @@ -88,11 +88,9 @@ pub(super) fn create_domain_data_trigger_v2( return Ok(result); } - // The parent is the domain whose label is the first segment of the parent - // name, under the rest of it - let (parent_domain_label, grand_parent_domain_name) = normalized_parent_domain_name - .split_once('.') - .unwrap_or((normalized_parent_domain_name.as_str(), "")); + // A parent domain name is a single label (its pattern holds no dot), so the + // parent is the top-level domain of that label + let parent_domain_label = normalized_parent_domain_name.as_str(); let document_type = data_contract.document_type_for_name( document_create_transition @@ -115,7 +113,7 @@ pub(super) fn create_domain_data_trigger_v2( WhereClause { field: NORMALIZED_PARENT_DOMAIN_NAME.to_string(), operator: WhereOperator::Equal, - value: Value::Text(grand_parent_domain_name.to_string()), + value: Value::Text(String::new()), }, ), ( @@ -189,3 +187,140 @@ pub(super) fn create_domain_data_trigger_v2( Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; + use crate::platform_types::platform::PlatformStateRef; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::platform_value::Bytes32; + use dpp::prelude::Identifier; + use dpp::state_transition::batch_transition::resolvers::v0::BatchTransitionResolversV0; + use dpp::tests::fixtures::{ + get_batched_transitions_fixture, get_dpns_data_contract_fixture, + get_dpns_parent_document_fixture, ParentDocumentOptions, + }; + use dpp::tests::utils::generate_random_identifier_struct; + use dpp::version::DefaultForPlatformVersion; + use drive::drive::contract::DataContractFetchInfo; + use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionAction; + use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionActionType; + use std::sync::Arc; + + /// Runs the trigger on the create of a top-level domain by `owner_id`, an + /// identity other than the DPNS contract owner. + fn top_level_domain_by( + owner_id: Identifier, + dry_run: bool, + ) -> (DataTriggerExecutionResult, bool) { + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let state = platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + }; + let platform_version = state + .current_platform_version() + .expect("expected a platform version"); + let protocol_version = state.current_protocol_version_in_consensus(); + + let document = get_dpns_parent_document_fixture( + ParentDocumentOptions { + owner_id, + ..Default::default() + }, + protocol_version, + ); + let data_contract = get_dpns_data_contract_fixture(Some(owner_id), 0, protocol_version) + .data_contract_owned(); + let document_type = data_contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let transitions = get_batched_transitions_fixture( + [( + DocumentTransitionActionType::Create, + vec![(document, document_type, Bytes32::default(), None)], + )], + &mut BTreeMap::new(), + ); + let document_create_transition = transitions + .first() + .expect("expected a transition") + .as_transition_create() + .expect("expected a document create transition"); + let action = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( + &platform.drive, + owner_id, + None, + document_create_transition, + &BlockInfo::default(), + 0, + |_identifier| { + Ok(Arc::new(DataContractFetchInfo::dpns_contract_fixture( + platform_version.protocol_version, + ))) + }, + platform_version, + ) + .expect("expected to create the action") + .0 + .into_data() + .expect("expected a valid transition"); + + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + if dry_run { + execution_context.enable_dry_run(); + } + let block_info = BlockInfo::default(); + let mut context = DataTriggerExecutionContext { + platform: &platform_ref, + block_info: &block_info, + owner_id: &owner_id, + state_transition_execution_context: &mut execution_context, + transaction: None, + }; + let result = create_domain_data_trigger_v2( + &action + .as_document_action() + .expect("expected a document action"), + &mut context, + platform_version, + ) + .expect("expected the trigger to run"); + let billed = !context + .state_transition_execution_context + .operations_slice() + .is_empty(); + (result, billed) + } + + #[test] + fn should_refuse_a_top_level_domain_from_another_identity() { + let (result, billed) = top_level_domain_by(generate_random_identifier_struct(), false); + + assert!(!result.is_valid()); + assert!(result + .errors + .first() + .expect("expected an error") + .to_string() + .contains("Can't create top level domain for this identity")); + assert!(!billed, "a top-level domain runs no parent query"); + } + + #[test] + fn should_judge_nothing_on_a_dry_run() { + let (result, billed) = top_level_domain_by(generate_random_identifier_struct(), true); + + assert!(result.is_valid()); + assert!(!billed, "a top-level domain runs no parent query"); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index bfda340566f..3619cdd06e7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -22,7 +22,6 @@ mod creation_tests { use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::document::Document; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - use dpp::util::hash::hash_double; use crate::test::helpers::dpns::dpns_salted_domain_hash; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; @@ -66,6 +65,12 @@ mod creation_tests { use crate::execution::validation::state_transition::tests::{create_card_game_external_token_contract_with_owner_identity, create_card_game_internal_token_contract_with_owner_identity_transfer_tokens, create_token_contract_with_owner_identity}; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::SuccessfulExecution; + /// The block after the one holding the preorders: from protocol version 14 a domain + /// reveals a preorder made in an earlier block. + fn block_after_the_preorders() -> BlockInfo { + BlockInfo::default_with_height(1) + } + #[tokio::test] async fn test_document_creation() { let platform_version = PlatformVersion::latest(); @@ -752,12 +757,7 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` - // from DPNS v3 (protocol version 14) - assert_eq!( - domain.documents_can_be_deleted(), - platform_version.system_data_contracts.dpns < 3 - ); + assert!(!domain.documents_can_be_deleted()); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -976,9 +976,7 @@ mod creation_tests { documents_batch_create_serialized_transition_2.clone(), ], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -1237,12 +1235,7 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` - // from DPNS v3 (protocol version 14) - assert_eq!( - domain.documents_can_be_deleted(), - platform_version.system_data_contracts.dpns < 3 - ); + assert!(!domain.documents_can_be_deleted()); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -1384,9 +1377,7 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -1677,9 +1668,7 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -2105,12 +2094,7 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` - // from DPNS v3 (protocol version 14) - assert_eq!( - domain.documents_can_be_deleted(), - platform_version.system_data_contracts.dpns < 3 - ); + assert!(!domain.documents_can_be_deleted()); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -2251,9 +2235,7 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_1.clone()], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -2401,12 +2383,7 @@ mod creation_tests { .expect("expected a profile document type"); assert!(!domain.documents_mutable()); - // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` - // from DPNS v3 (protocol version 14) - assert_eq!( - domain.documents_can_be_deleted(), - platform_version.system_data_contracts.dpns < 3 - ); + assert!(!domain.documents_can_be_deleted()); assert!(domain.documents_transferable().is_transferable()); let entropy = Bytes32::random_with_rng(&mut rng); @@ -2822,9 +2799,7 @@ mod creation_tests { documents_batch_create_serialized_transition_2.clone(), ], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -2848,9 +2823,7 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_3.clone()], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, @@ -2877,9 +2850,7 @@ mod creation_tests { .process_raw_state_transitions( &vec![documents_batch_create_serialized_transition_4], &platform_state, - // The block after the preorders: from protocol version 14 a domain - // reveals a preorder made in an earlier block - &BlockInfo::default_with_height(1), + &block_after_the_preorders(), &transaction, platform_version, false, 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 1150ca7c52d..47a89d38749 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 @@ -2889,7 +2889,6 @@ mod dpns_username_transfer_tests { async fn test_dpns_username_replace_and_delete_still_rejected() { run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( PlatformVersion::latest().protocol_version, - false, ) .await; } @@ -2897,13 +2896,12 @@ mod dpns_username_transfer_tests { /// PROTOCOL_VERSION_13: DPNS v2's `domain` sets `canBeDeleted: true`, so /// `Delete` is stopped only by the reject data trigger. #[tokio::test] - async fn test_dpns_username_replace_and_delete_still_rejected_protocol_version_13() { - run_dpns_username_replace_and_delete_still_rejected_at_protocol_version(13, true).await; + async fn should_reject_dpns_username_replace_and_delete_at_protocol_version_13() { + run_dpns_username_replace_and_delete_still_rejected_at_protocol_version(13).await; } async fn run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( protocol_version: ProtocolVersion, - delete_rejected_by_data_trigger: bool, ) { let mut platform = TestPlatformBuilder::new() .with_initial_protocol_version(protocol_version) @@ -2914,6 +2912,8 @@ mod dpns_username_transfer_tests { let platform_version = platform_state .current_platform_version() .expect("expected to get current platform version"); + // DPNS v2's `domain` may be deleted, so only the reject data trigger stops it + let delete_rejected_by_data_trigger = platform_version.system_data_contracts.dpns < 3; let mut rng = StdRng::seed_from_u64(439); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index 01659645103..ebacfeb614e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -16,8 +16,8 @@ mod dpns_v3_tests { }; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::dpns::dpns_salted_domain_hash; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; use crate::test::helpers::setup::TempPlatform; - use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; use dpp::consensus::basic::BasicError; use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::state::data_trigger::DataTriggerError; @@ -370,30 +370,28 @@ mod dpns_v3_tests { .pop() } - /// The stored preorder `preorder` is, found by its unique index: its owner - /// and salted hash from DPNS v3, its salted hash before it. + /// The stored preorder `preorder` is, found by its id. fn stored_preorder(&self, preorder: &Document) -> Option { - let salted_domain_hash = preorder - .properties() - .get("saltedDomainHash") - .expect("expected the preorder's salted hash") - .clone(); - if self.platform_version.system_data_contracts.dpns >= 3 { - self.query( - "preorder", - &[ - ( - "$ownerId", - Value::Identifier(preorder.owner_id().to_buffer()), - ), - ("saltedDomainHash", salted_domain_hash), - ], + let dpns = self.dpns(); + let document_type = dpns + .document_type_for_name("preorder") + .expect("expected the preorder document type"); + self.platform + .drive + .query_documents( + DriveDocumentQuery::new_primary_key_single_item_query( + &dpns, + document_type, + preorder.id(), + ), + None, + false, + None, + Some(self.platform_version.protocol_version), ) + .expect("expected to query the preorder") + .documents_owned() .pop() - } else { - self.query("preorder", &[("saltedDomainHash", salted_domain_hash)]) - .pop() - } } /// Runs the protocol version 14 activation at block `height` on a @@ -1019,10 +1017,14 @@ mod dpns_v3_tests { // The rebuilt preorder type holds nothing and is laid out as a chain born at // protocol version 14 lays it out assert!(fixture.stored_preorder(&pending).is_none()); - let born_at_14 = DpnsFixture::new(14); + // A genesis state is all such a chain needs: no identities + let born_at_14 = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); assert_eq!( preorder_type_layout(&fixture.platform, fixture.platform_version), - preorder_type_layout(&born_at_14.platform, born_at_14.platform_version) + preorder_type_layout(&born_at_14, fixture.platform_version) ); // Alice's pending preorder is gone, so she preorders again @@ -1191,25 +1193,14 @@ mod dpns_v3_tests { core_height: 42, epoch: Default::default(), }; - let mut ended_state = platform.state.load().as_ref().clone(); - ended_state.set_last_committed_block_info(Some( - ExtendedBlockInfoV0 { - basic_info: block_info, - app_hash: platform - .drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .expect("expected the root hash"), - quorum_hash: [0u8; 32], - block_id_hash: [0u8; 32], - proposer_pro_tx_hash: [0u8; 32], - signature: [0u8; 96], - round: 0, - } - .into(), - )); - platform.state.store(Arc::new(ended_state)); + fast_forward_to_block( + &platform, + block_info.time_ms, + block_info.height, + 42, + 0, + false, + ); let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); platform diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index fdd7849db0e..463d2daf286 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -2,7 +2,6 @@ #[cfg(test)] pub mod contract_groups; /// DPNS fixtures -#[cfg(test)] pub mod dpns; /// Test helpers #[cfg(test)] diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index d1acf8ad760..d356593b6f2 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -31,7 +31,7 @@ mod tests { use dpp::data_contract::DataContract; use dpp::platform_value::Identifier; use dpp::state_transition::StateTransition; - use dpp::util::hash::hash_double; + use drive_abci::test::helpers::dpns::dpns_salted_domain_hash; use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use drive::util::object_size_info::DataContractOwnedResolvedInfo; @@ -45,14 +45,15 @@ mod tests { const STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB - /// The `preorder` op of `owner_id` for `label` under `dash` salted with `salt`, and the - /// `domain` op revealing it. From protocol version 14 a domain must reveal its owner's + /// The `preorder` op of `owner_id` for `label` under `dash` salted with `salt`, hashed as + /// the DPNS contract of `platform_version` declares, and the `domain` op revealing it. From protocol version 14 a domain must reveal its owner's /// preorder made in an earlier block, so the preorder op runs in a block before the domain op. fn dpns_preorder_and_domain_ops( dpns_contract: &DataContract, owner_id: Identifier, label: &str, salt: [u8; 32], + platform_version: &PlatformVersion, ) -> (DocumentOp, DocumentOp) { let normalized_label = convert_to_homograph_safe_chars(label); let domain_values: BTreeMap = BTreeMap::from([ @@ -66,33 +67,8 @@ mod tests { BTreeMap::from([("identity", Value::from(owner_id))]).into(), ), ]); - // The hash the domain's `preorderSalt` declares from DPNS v3 (the owner's id, the - // salt, the normalized label, "." and the parent); before it, of the salt and - // `.dash` - let domain_type = dpns_contract - .document_type_for_name("domain") - .expect("expected the domain document type"); - let declared_key = domain_type - .flattened_properties() - .get("preorderSalt") - .and_then(|property| property.revealed_reference.as_ref()) - .and_then(|target| target.as_any_document_reference()) - .and_then(|declaration| declaration.lookup) - .and_then(|lookup| lookup.hash_key()) - .map(|(_, key)| key); - let salted_domain_hash = match declared_key { - Some(key) => { - key.key_value(domain_type, None, owner_id, &domain_values) - .expect("expected the preorder hash") - .0 - } - None => { - let mut salted_domain_name = salt.to_vec(); - salted_domain_name.extend(normalized_label.as_bytes()); - salted_domain_name.extend(b".dash"); - hash_double(salted_domain_name) - } - }; + let salted_domain_hash = + dpns_salted_domain_hash(owner_id, &salt, &normalized_label, "dash", platform_version); let preorder_op = DocumentOp { contract: dpns_contract.clone(), @@ -220,7 +196,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, epoch_time_length_s, ..Default::default() @@ -268,8 +243,13 @@ mod tests { .clone(); let identity1_id = start_identities.first().unwrap().0.id(); - let (preorder_op_1, domain_op_1) = - dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); + let (preorder_op_1, domain_op_1) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity1_id, + "quantum", + [1u8; 32], + platform_version, + ); let strategy = NetworkStrategy { strategy: Strategy { @@ -349,7 +329,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -627,7 +606,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -687,10 +665,20 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let (preorder_op_1, domain_op_1) = - dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); - let (preorder_op_2, domain_op_2) = - dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); + let (preorder_op_1, domain_op_1) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity1_id, + "quantum", + [1u8; 32], + platform_version, + ); + let (preorder_op_2, domain_op_2) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity2_id, + "quantum", + [2u8; 32], + platform_version, + ); let strategy = NetworkStrategy { strategy: Strategy { @@ -942,7 +930,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -1002,10 +989,20 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let (preorder_op_1, domain_op_1) = - dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); - let (preorder_op_2, domain_op_2) = - dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); + let (preorder_op_1, domain_op_1) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity1_id, + "quantum", + [1u8; 32], + platform_version, + ); + let (preorder_op_2, domain_op_2) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity2_id, + "quantum", + [2u8; 32], + platform_version, + ); let strategy = NetworkStrategy { strategy: Strategy { @@ -1267,7 +1264,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -1472,7 +1468,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -1703,7 +1698,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -1775,10 +1769,20 @@ mod tests { let identity1_id = start_identities.first().unwrap().0.id(); let identity2_id = start_identities.last().unwrap().0.id(); - let (preorder_op_1, domain_op_1) = - dpns_preorder_and_domain_ops(&dpns_contract, identity1_id, "quantum", [1u8; 32]); - let (preorder_op_2, domain_op_2) = - dpns_preorder_and_domain_ops(&dpns_contract, identity2_id, "quantum", [2u8; 32]); + let (preorder_op_1, domain_op_1) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity1_id, + "quantum", + [1u8; 32], + platform_version, + ); + let (preorder_op_2, domain_op_2) = dpns_preorder_and_domain_ops( + &dpns_contract, + identity2_id, + "quantum", + [2u8; 32], + platform_version, + ); let strategy = NetworkStrategy { strategy: Strategy { @@ -1875,7 +1879,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -2080,7 +2083,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -2291,7 +2293,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, @@ -2516,7 +2517,6 @@ mod tests { chain_lock: ChainLockConfig::default_100_67(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { - //we disable document triggers because we are using dpns and dpns needs a preorder use_document_triggers: false, ..Default::default() }, diff --git a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs index 34bfd7f7c63..3ec23d1e961 100644 --- a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs +++ b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs @@ -1,15 +1,26 @@ use crate::drive::{contract_documents_path, Drive}; use crate::error::drive::DriveError; use crate::error::Error; -use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::DataContract; use dpp::serialization::PlatformSerializableWithPlatformVersion; use dpp::version::PlatformVersion; use grovedb::operations::delete::DeleteOptions; -use grovedb::{Element, Transaction}; -use std::borrow::Cow; +use grovedb::{BackwardsReferences, Element, Transaction}; + +/// Whether a contract update lays `document_type` out as a registration does, with nothing of +/// it outside its own subtree: an `indexOnly` type has no primary key tree when registered, a +/// contested index keeps vote polls, moderator deletions keep removal records and a ttl keeps +/// expiration entries, none of which a rebuild deletes or lays out. +fn rebuilds_like_a_registration(document_type: DocumentTypeRef) -> bool { + !document_type.index_only() + && document_type.find_contested_index().is_none() + && !document_type.documents_can_be_deleted_by_moderators() + && document_type.documents_ttl_seconds().is_none() +} impl Drive { /// Re-stores `contract` over the stored version of the same contract, as `apply_contract` @@ -17,7 +28,8 @@ impl Drive { /// its whole subtree, every document and index entry under it, is deleted, and the update /// then creates the type as it creates a type a contract update adds, which is how a /// contract registration creates it too. The rebuilt types end up holding no documents, - /// laid out exactly as on a chain that registered `contract` directly. + /// laid out exactly as on a chain that registered `contract` directly. A type with trees + /// outside its own subtree ([`rebuilds_like_a_registration`]) is refused. /// /// For a protocol upgrade that changes a system contract's document type in a way a /// contract update cannot follow, such as replacing a unique index; the documents deleted @@ -29,7 +41,6 @@ impl Drive { contract: &DataContract, rebuilt_document_types: &[&str], block_info: &BlockInfo, - storage_flags: Option>, transaction: &Transaction, platform_version: &PlatformVersion, ) -> Result<(), Error> { @@ -54,15 +65,23 @@ impl Drive { // creates each of them as a type the update adds let mut original_contract = stored_contract; for document_type_name in rebuilt_document_types { - if original_contract + let stored_type = original_contract .document_types_mut() - .remove(*document_type_name) - .is_none() - || !contract.has_document_type_for_name(document_type_name) - { + .remove(*document_type_name); + let (Some(stored_type), Ok(new_type)) = ( + stored_type, + contract.document_type_for_name(document_type_name), + ) else { return Err(Error::Drive(DriveError::CorruptedCodeExecution( "a rebuilt document type must be in both the stored and the new contract", ))); + }; + if !rebuilds_like_a_registration(stored_type.as_ref()) + || !rebuilds_like_a_registration(new_type) + { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a rebuilt document type may keep no trees outside its own subtree", + ))); } self.grove .delete( @@ -71,6 +90,8 @@ impl Drive { Some(DeleteOptions { allow_deleting_non_empty_trees: true, deleting_non_empty_trees_returns_error: false, + // Drive stores no backward-reference participants + backwards_references: BackwardsReferences::DontCheck, ..Default::default() }), Some(transaction), @@ -80,9 +101,10 @@ impl Drive { .map_err(Error::from)?; } + // A system contract, stored without storage flags let contract_element = Element::Item( contract.serialize_to_bytes_with_platform_version(platform_version)?, - StorageFlags::map_cow_to_some_element_flags(storage_flags), + None, ); let mut drive_operations = vec![]; self.update_contract_add_operations( @@ -166,7 +188,6 @@ mod tests { &dpns_v3, &["preorder"], &BlockInfo::default(), - None, &transaction, platform_version, ) diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index d0491c55d67..fff66d942ca 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -12,8 +12,9 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // most of its data trigger: generated normalized names, the reveal and // deletion of the writer's own preorder from an earlier block (preorders unique // per owner, their hash binding the owner), and the new domain's identity record -// held to its owner. Domains can no longer be deleted. v2 (dashpay: 1, withdrawals: 1, token_history: 1, dpns: 2) remains -// for PROTOCOL_VERSION_13 chain replay. +// held to its owner. Domains can no longer be deleted. v2 (dashpay: 1, +// withdrawals: 1, token_history: 1, dpns: 2) remains for PROTOCOL_VERSION_13 +// chain replay. // // The app-connect contract (app_connect: 1) also activates with // PROTOCOL_VERSION_14: it is registered at genesis from that version on and diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index dde317b809d..edc6bb94d30 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -71,8 +71,6 @@ fn hash_double(data: Vec) -> [u8; 32] { fn salted_domain_hash( domain_document_type: DocumentTypeRef, owner_id: Identifier, - salt: [u8; 32], - normalized_label: &str, domain_properties: &BTreeMap, ) -> Result<[u8; 32], Error> { let Some(reference) = domain_document_type @@ -80,6 +78,14 @@ fn salted_domain_hash( .get("preorderSalt") .and_then(|property| property.revealed_reference.as_ref()) else { + let (Some(Value::Bytes32(salt)), Some(Value::Text(normalized_label))) = ( + domain_properties.get("preorderSalt"), + domain_properties.get("normalizedLabel"), + ) else { + return Err(Error::Generic( + "a DPNS domain carries a 32-byte preorderSalt and a normalizedLabel".to_string(), + )); + }; let mut salted_domain_buffer = salt.to_vec(); salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); return Ok(hash_double(salted_domain_buffer)); @@ -298,13 +304,8 @@ impl Sdk { ]); // The salted domain hash the preorder commits to - let salted_domain_hash = salted_domain_hash( - domain_document_type, - identity_id, - salt, - &normalized_label, - &domain_properties, - )?; + let salted_domain_hash = + salted_domain_hash(domain_document_type, identity_id, &domain_properties)?; // Create preorder document let preorder_document = Document::V0(DocumentV0 { @@ -728,7 +729,7 @@ mod tests { let domain = contract .document_type_for_name("domain") .expect("the DPNS contract has a domain type"); - salted_domain_hash(domain, owner_id, SALT, "b0b", properties) + salted_domain_hash(domain, owner_id, properties) } fn sha256d(parts: &[&[u8]]) -> [u8; 32] { diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index b0a5117fe0b..0324b5f43d7 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -338,9 +338,11 @@ export type DocumentReferenceWhere = Record; * joined in order with nothing between them. A param is a property path of the * document being created (a string's UTF-8, a byte array's bytes, an * identifier's 32 bytes), the property carrying the reference included, - * `{ const }`, fixed UTF-8 text, or `'.'`, the value carrying the reference + * `{ const }`, fixed UTF-8 text, `'.'`, the value carrying the reference * where it has no path (each element of a typed array, the writer or the - * creator). The document it finds is a commitment made earlier; the reference + * creator), or `'$ownerId'`, the 32 bytes of the writer's identity, the owner + * of the document being created. The document it finds is a commitment made + * earlier; the reference * is checked when the document is created only. To reveal one, compute the * same hash over the values you create the document with. */ From a9b1ee56b6c7232a36df2612a12ba1415a6dedaf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 15:10:52 +0200 Subject: [PATCH 12/62] refactor(dpp): read a reference's findBy hash key through one accessor `DocumentPropertyReferenceTarget::lookup_hash_key` returns the computed key of the `findBy` function a target declares, the chain `as_any_document_reference` -> `lookup` -> `hash_key` that rs-sdk's DPNS preorder hash and document reference validation 0 each spelled out. Reference validation 0 is a shipped generation edited in place: the accessor returns exactly what the inline chain returned, so nothing changes at any protocol version. Co-Authored-By: Claude Opus 5.5 --- .../data_contract/document_type/property/mod.rs | 9 +++++++++ .../document_reference_validation/v0/mod.rs | 6 +----- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 15 +++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 11489d5c03f..110ebce418e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -1177,6 +1177,15 @@ impl DocumentPropertyReferenceTarget { } } + /// The computed key of the `findBy` function this target declares, the one + /// key a commit and reveal hashes; `None` for a target without one. + pub fn lookup_hash_key(&self) -> Option<&LookupHashKey> { + self.as_any_document_reference() + .and_then(|declaration| declaration.lookup) + .and_then(|lookup| lookup.hash_key()) + .map(|(_, key)| key) + } + /// The declaration of a reference with `inList`; `None` for every other /// target. pub fn as_list_element_reference(&self) -> Option<&ListElementReference> { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index c76a588e6b6..285f187edc0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -353,11 +353,7 @@ fn validate_document_type_references_v0( Ok(None) => continue, Err(RepeatedKey) => return preimage_invalid(), } - let Some((_, key)) = target - .as_any_document_reference() - .and_then(|declaration| declaration.lookup) - .and_then(|lookup| lookup.hash_key()) - else { + let Some(key) = target.lookup_hash_key() else { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "a revealed reference carries a lookup with a computed key, which the \ parser enforces", diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index edc6bb94d30..56e94ab5f72 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -90,16 +90,11 @@ fn salted_domain_hash( salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); return Ok(hash_double(salted_domain_buffer)); }; - let key = reference - .as_any_document_reference() - .and_then(|declaration| declaration.lookup) - .and_then(|lookup| lookup.hash_key()) - .map(|(_, key)| key) - .ok_or_else(|| { - Error::Generic( - "the DPNS domain's preorderSalt reference declares no findBy function".to_string(), - ) - })?; + let key = reference.lookup_hash_key().ok_or_else(|| { + Error::Generic( + "the DPNS domain's preorderSalt reference declares no findBy function".to_string(), + ) + })?; key.key_value(domain_document_type, None, owner_id, domain_properties) .map(|(hash, _)| hash) .map_err(|error| { From 0e3ae00683f392beaf36bff5ea884faeb3daa5ab Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 15:45:47 +0200 Subject: [PATCH 13/62] test(sdk): give the records.identity refusal spec two distinct identities The register spec mocks `crypto.randomBytes` to zeros for the whole suite, so two `generateRandomIdentifier()` calls gave the same identity and the refusal never fired. The spec now uses two fixed, different ids. Co-Authored-By: Claude Opus 5.5 --- .../src/SDK/Client/Platform/methods/names/register.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 851a9c66135..523b165eb72 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import { Identifier } from '@dashevo/wasm-dpp'; import { ImportMock } from 'ts-mock-imports'; import generateRandomIdentifier from '@dashevo/wasm-dpp/lib/test/utils/generateRandomIdentifierAsync'; @@ -254,7 +255,9 @@ describe('Platform', () => { }); it('should refuse records.identity other than the registering identity under DPNS v3', async () => { - identityMock.getId.returns(await generateRandomIdentifier()); + // Fixed ids: `crypto.randomBytes` is mocked to zeros here, so two random + // identifiers would be the same + identityMock.getId.returns(Identifier.from(Buffer.alloc(32, 1))); platformMock.contracts.get.resolves({ getDocumentSchema: dpnsV3DomainSchema, }); @@ -262,7 +265,7 @@ describe('Platform', () => { let error; try { await register.call(platformMock, 'User.dash', { - identity: await generateRandomIdentifier(), + identity: Identifier.from(Buffer.alloc(32, 2)), }, identityMock); } catch (e: any) { error = e; From c2d30d99c63cc1256c18cccaef3185d7d32f99ee Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 15:52:57 +0200 Subject: [PATCH 14/62] docs(book): the findBy function exception for $ownerId; DPNS preorders stay deletable documents.md stated without exception that a findBy `"$ownerId"` source needs a referring type that cannot be transferred or traded, for a property's findBy and for creatorRefersTo; beside a function the lookup is judged on the create alone and the source is allowed, as DPNS v3 declares. The deletion chapter's onlyWhenConsumed preorder example now says DPNS v3's own preorder keeps `canBeDeleted: true`. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/deletion.md | 2 +- book/src/data-model/documents.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/book/src/contract-keywords/deletion.md b/book/src/contract-keywords/deletion.md index 6863e4c4789..eb64fdb7b75 100644 --- a/book/src/contract-keywords/deletion.md +++ b/book/src/contract-keywords/deletion.md @@ -82,7 +82,7 @@ A commenter may take a comment down at any time. Since `true` is the usual defau } ``` -A preorder stays until the name registration that reveals it consumes it (the `preorderSalt` declaration in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. +A preorder of this type stays until the name registration that reveals it consumes it (the `preorderSalt` declaration in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. DPNS v3's own `preorder` declares the same index but `canBeDeleted: true`, so its owner may still delete a preorder it no longer wants; this example is the stricter variant. - A delete transition of such a document is refused (`InvalidDocumentTransitionActionError`, 10404), as for `false`. - A consume deletes it as a delete by its owner would, its storage refunded to the owner. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index fb38c552b6a..d2625fcd091 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -352,7 +352,7 @@ A `deletableDocument` reference may carry `findBy` too, and then promises less t What is checked when the contract enters the chain, on registration and on update: - `findBy` is only allowed on `permanentDocument` and `deletableDocument` references, and `where` on those and `moderatedDocument` ones (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. -- Each property an entry reads must exist on the referring type, be required (and so must every object around it), not be transient nor sit inside a transient object, and hold a single value, so `findBy` never runs with a missing key part and a reader can assemble the same key from the stored document. An entry that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. These are properties of the referring type alone and are checked on every parse (generation 3). +- Each property an entry reads must exist on the referring type, be required (and so must every object around it), not be transient nor sit inside a transient object, and hold a single value, so `findBy` never runs with a missing key part and a reader can assemble the same key from the stored document. An entry that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. A `findBy` holding a function is the exception: its lookup is judged when the document is created only (see [Commit and reveal](#commit-and-reveal-a-findby-function)), so a later transfer moves no key it reads. These are properties of the referring type alone and are checked on every parse (generation 3). - The entries must name exactly the properties of a unique index of the referenced type, and nothing else. One naming no such index is refused with the unique indexes the type has (`"joinRequest" has no unique index over exactly (submittedCharterId): findBy must name every property of one of its unique indexes and nothing else (bySubmittedCharter (submittedCharterId, $ownerId))`), and one naming exactly the properties of an index that is not unique is refused too, since it could find several documents (`index "byOwner" of "joinRequest" over ($ownerId) is not unique: findBy must find at most one document`). The index may not bucket its first property with `timeRange` or `integerRange`, and the referenced type may not be `indexOnly`. Each source must hold the same kind of value as the property it fills (the rule of `where`, `DocumentPropertyType::value_kind`). - The key must stay with the document it found, or the reference could dangle without the document being deleted: every schema property of the index must be fixed once written (the referenced type is immutable, or the property, or the top-level object holding it, is listed under `immutable` and is no optional `deletableDocument` reference by id, which a replace may clear once its document is deleted), `$ownerId` is only a key part on a type whose documents can be neither transferred nor traded, and the update and transfer times are refused where a replace, transfer or purchase moves them. `$id`, `$creatorId` and the creation times are always fixed. - `{ "$id": }` is admitted only with `inList`, as its only entry (see [An element of a list](#an-element-of-a-list-inlist)). @@ -505,7 +505,7 @@ A document type whose documents can be transferred or traded declares `creatorRe } ``` -- It takes the same targets but a `deletableDocument` (the creator never changes, and a document a transfer handed on could not be replaced once the one `findBy` found is deleted), except one found by a `findBy` function, which is judged on the create alone (see [Commit and reveal](#commit-and-reveal-a-findby-function)), with `"."` the creator in `findBy`, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` source in its `findBy` is refused, as in a property's `findBy` on such a type, since the owner moves. +- It takes the same targets but a `deletableDocument` (the creator never changes, and a document a transfer handed on could not be replaced once the one `findBy` found is deleted), except one found by a `findBy` function, which is judged on the create alone (see [Commit and reveal](#commit-and-reveal-a-findby-function)), with `"."` the creator in `findBy`, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` source in its `findBy` is refused, as in a property's `findBy` on such a type, since the owner moves, unless the `findBy` holds a function, whose lookup is judged on the create alone. - When a document is created its creator is the writer; on a replace, the value is the stored creator, whoever writes, and the replace rules are those of its target, as for the owner reference. A transfer or a purchase needs no check. A failure is the target's error at the path `$creatorId`, and registration names the declaration `.$creatorId`. An `identity` target reads nothing: the creator existed when it wrote the document, and an identity is never removed. It counts one against `max_references_per_document`, and a change to it is an incompatible schema change on update. ### Commit and reveal (a `findBy` function) From b43fcec74bbe352d6565f6d22b462796deb0ebc8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 16:26:31 +0200 Subject: [PATCH 15/62] test(drive): run the protocol 13 halves of three tests on DPNS v2 Three Drive tests used the latest DPNS fixture for writes or proofs at protocol version 13. DPNS v3 is now written with the `bytes` and `identifier` type shorthands, which the protocol 13 parser refuses ("unsupported property type: bytes"). A chain at 13 stores DPNS v2, which every later version reads too, so these tests now load it: the two contested-poll tests (`..._poll_started_before_protocol_version_14`) and `verify_batch_document_proof_before_protocol_version_14_carries_no_balance`. Co-Authored-By: Claude Opus 5.5 --- .../v1/mod.rs | 18 +++++++++++------- .../v0/mod.rs | 18 +++++++++++------- .../v0/mod.rs | 12 +++++++++--- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs index 5db80572fe4..5c325265b29 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs @@ -336,14 +336,16 @@ mod tests { /// A drive holding the DPNS contract fn drive_with_dpns() -> (Drive, DataContract) { + drive_with_dpns_of(PlatformVersion::latest().protocol_version) + } + + /// A drive holding the DPNS contract of `protocol_version` + fn drive_with_dpns_of(protocol_version: u32) -> (Drive, DataContract) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let dpns_contract = get_dpns_data_contract_fixture( - Some(Identifier::from([7; 32])), - 0, - platform_version.protocol_version, - ) - .data_contract_owned(); + let dpns_contract = + get_dpns_data_contract_fixture(Some(Identifier::from([7; 32])), 0, protocol_version) + .data_contract_owned(); drive .apply_contract( &dpns_contract, @@ -414,7 +416,9 @@ mod tests { #[test] fn should_keep_the_plain_tree_of_a_poll_started_before_protocol_version_14() { let platform_version = PlatformVersion::latest(); - let (drive, dpns_contract) = drive_with_dpns(); + // A chain running a contest at protocol version 13 stores DPNS v2, which every later + // version reads too; v3 is written with type shorthands protocol version 13 does not parse + let (drive, dpns_contract) = drive_with_dpns_of(13); add_dpns_name_contenders( &drive, &dpns_contract, diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs index da8a7430d29..99e977041f0 100644 --- a/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs @@ -121,14 +121,16 @@ mod tests { /// A drive holding the DPNS contract fn drive_with_dpns() -> (Drive, DataContract) { + drive_with_dpns_of(PlatformVersion::latest().protocol_version) + } + + /// A drive holding the DPNS contract of `protocol_version` + fn drive_with_dpns_of(protocol_version: u32) -> (Drive, DataContract) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let dpns_contract = get_dpns_data_contract_fixture( - Some(Identifier::from([7; 32])), - 0, - platform_version.protocol_version, - ) - .data_contract_owned(); + let dpns_contract = + get_dpns_data_contract_fixture(Some(Identifier::from([7; 32])), 0, protocol_version) + .data_contract_owned(); drive .apply_contract( &dpns_contract, @@ -239,7 +241,9 @@ mod tests { fn should_count_the_contenders_of_a_poll_started_before_protocol_version_14() { let platform_version = PlatformVersion::latest(); let protocol_version_13 = PlatformVersion::get(13).expect("expected version 13"); - let (drive, dpns_contract) = drive_with_dpns(); + // A chain running a contest at protocol version 13 stores DPNS v2, which every later + // version reads too; v3 is written with type shorthands protocol version 13 does not parse + let (drive, dpns_contract) = drive_with_dpns_of(13); add_dpns_name_contenders( &drive, &dpns_contract, diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 554582643fe..b003c6a6fb4 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -3653,10 +3653,14 @@ mod tests { /// Helper: set up drive, insert a DPNS contract, return (drive, contract) fn setup_drive_and_contract() -> (Drive, DataContract) { + setup_drive_and_dpns_contract_of(PlatformVersion::latest().protocol_version) + } + + /// Helper: set up drive with the DPNS contract of `protocol_version` + fn setup_drive_and_dpns_contract_of(protocol_version: u32) -> (Drive, DataContract) { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); - let created_contract = - get_dpns_data_contract_fixture(None, 0, platform_version.protocol_version); + let created_contract = get_dpns_data_contract_fixture(None, 0, protocol_version); let contract = created_contract.data_contract_owned(); drive .insert_contract( @@ -4659,7 +4663,9 @@ mod tests { /// exactly the single-document proof a pre-14 client rebuilds strictly. #[test] fn verify_batch_document_proof_before_protocol_version_14_carries_no_balance() { - let (drive, contract) = setup_drive_and_contract(); + // A chain at protocol version 13 stores DPNS v2, which every later version reads too; + // v3 is written with type shorthands protocol version 13 does not parse + let (drive, contract) = setup_drive_and_dpns_contract_of(13); let latest = PlatformVersion::latest(); let before_balances = PlatformVersion::get(13).expect("protocol version 13 exists"); assert_eq!( From c158a7db2d82427175ea3fb684fb12f552bf4704 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 17:43:12 +0200 Subject: [PATCH 16/62] fix(sdk): parse a proof the SDK's version can not read under the version the response reports A fresh SDK starts at its network's floor (protocol version 13 on mainnet, testnet and regtest) and learns the network's version only from a verified response. DPNS v3 is written with the `bytes` and `identifier` type shorthands, which the protocol version 13 parser refuses, so on a network at 14 the first proof holding DPNS failed ("unsupported property type: bytes") before the SDK could learn 14, every time. When parsing under the SDK's version fails on an unpinned SDK below the newest version it knows, `parse_proof_at_the_reported_version` parses under that newest version, which verifies the proof and its quorum signature over a state id that signs the response's protocol version, and, when the reported version is another known one, parses again under it. A pinned SDK, or one at the newest version, reports the first failure. The fetch traits require cloneable responses (all are) for the retry. Tests: a stand-in proof readable only from protocol version 14 parses on an SDK seeded at 13 and fails on one pinned at 13. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/platform/fetch.rs | 2 +- packages/rs-sdk/src/platform/fetch_many.rs | 2 +- packages/rs-sdk/src/sdk.rs | 181 +++++++++++++++++++-- 3 files changed, 167 insertions(+), 18 deletions(-) diff --git a/packages/rs-sdk/src/platform/fetch.rs b/packages/rs-sdk/src/platform/fetch.rs index 6a7d3bd7702..196e52beac7 100644 --- a/packages/rs-sdk/src/platform/fetch.rs +++ b/packages/rs-sdk/src/platform/fetch.rs @@ -85,7 +85,7 @@ where /// `Self::Query = Self::Request`; for versioned ones the /// [`Query::query`] impl on [`Self::Query`] performs the /// protocol-version-aware wire encoding using `&Sdk`. - type Request: TransportRequest; + type Request: TransportRequest; /// Fetch single object from Platform. /// diff --git a/packages/rs-sdk/src/platform/fetch_many.rs b/packages/rs-sdk/src/platform/fetch_many.rs index c2bd33bd19e..983ad107e21 100644 --- a/packages/rs-sdk/src/platform/fetch_many.rs +++ b/packages/rs-sdk/src/platform/fetch_many.rs @@ -112,7 +112,7 @@ where + Sync; /// Wire-encoded request that hits the network. - type Request: TransportRequest; + type Request: TransportRequest; /// Fetch (or search) multiple objects on the Dash Platform /// diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 73e3773b5e6..f3d7ce98992 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -499,14 +499,17 @@ impl Sdk { /// because no network response has been received yet to teach the SDK the real network version. /// /// The actual network version is learned only *after* proof parsing succeeds, when - /// [`Self::verify_response_metadata()`] processes `metadata.protocol_version`. If the - /// connected network runs an older protocol version **and** proof interpretation differs - /// between that version and the seeded [`min_protocol_version`], the very first request may - /// fail before the SDK can correct itself. Subsequent requests will use the correct version. - /// - /// This is a known bootstrap limitation. Callers that must guarantee correct version - /// behaviour on the first request should pin the version explicitly via - /// [`SdkBuilder::with_version()`]. + /// [`Self::verify_response_metadata()`] processes `metadata.protocol_version`. A proof the + /// SDK's version can not read, such as a contract written with grammar only a newer + /// protocol version parses, is read as [`Self::parse_proof_at_the_reported_version`] sets + /// out: under the newest version this SDK knows, then under the version the response + /// reports, which the proof's quorum signature covers. If the connected network runs an + /// older protocol version **and** proof interpretation differs between that version and the + /// seeded [`min_protocol_version`] without failing, the very first request may still read + /// the proof the seeded way. Subsequent requests will use the correct version. + /// + /// Callers that must guarantee correct version behaviour on the first request should pin + /// the version explicitly via [`SdkBuilder::with_version()`]. pub(crate) async fn parse_proof_with_metadata_and_proof + MockResponse>( &self, request: O::Request, @@ -514,20 +517,17 @@ impl Sdk { method_name: &'static str, ) -> Result<(Option, ResponseMetadata, Proof), Error> where - O::Request: Mockable, + O::Request: Mockable + Clone, + O::Response: Clone, { let provider = self .context_provider() .ok_or(drive_proof_verifier::Error::ContextProviderNotSet)?; let (object, metadata, proof) = match self.inner { - SdkInstance::Dapi { .. } => O::maybe_from_proof_with_metadata( - request, - response, - self.network, - self.version(), - &provider, - ), + SdkInstance::Dapi { .. } => { + self.parse_proof_at_the_reported_version::(request, response, &provider) + } #[cfg(feature = "mocks")] SdkInstance::Mock { ref mock, .. } => { let guard = mock.lock().await; @@ -547,6 +547,72 @@ impl Sdk { Ok((object, metadata, proof)) } + /// Parses and verifies `response` under the SDK's protocol version, and, when that fails on + /// an SDK that learns its version from the network, under the version the response reports. + /// + /// A fresh SDK starts at its network's [`min_protocol_version`], and a proof can hold what + /// only a newer protocol version reads, such as a contract written with the `bytes` and + /// `identifier` property type shorthands of protocol version 14. The version is learned + /// from a verified response, so such a proof would fail every time. The response is then + /// parsed under the newest version this SDK knows, which verifies the proof and its quorum + /// signature, over a state id that signs the protocol version the response reports, and, + /// when that version is another one this SDK knows, parsed again under it. A pinned SDK, or + /// one already at the newest version it knows, reports the first failure. + fn parse_proof_at_the_reported_version>( + &self, + request: O::Request, + response: O::Response, + provider: &dyn ContextProvider, + ) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> + where + O::Request: Clone, + O::Response: Clone, + { + let current = self.version(); + let latest = PlatformVersion::latest(); + let error = match O::maybe_from_proof_with_metadata( + request.clone(), + response.clone(), + self.network, + current, + provider, + ) { + Ok(parsed) => return Ok(parsed), + Err(error) => error, + }; + if self.version_pinned || current.protocol_version >= latest.protocol_version { + return Err(error); + } + let Ok(parsed) = O::maybe_from_proof_with_metadata( + request.clone(), + response.clone(), + self.network, + latest, + provider, + ) else { + return Err(error); + }; + let reported = parsed.1.protocol_version; + match PlatformVersion::get(reported) { + Ok(reported) if reported.protocol_version != latest.protocol_version => { + tracing::debug!( + target: "dash_sdk::protocol_version", + current = current.protocol_version, + reported = reported.protocol_version, + "parsed a proof under the protocol version its response reports" + ); + O::maybe_from_proof_with_metadata( + request, + response, + self.network, + reported, + provider, + ) + } + _ => Ok(parsed), + } + } + /// Return [ContextProvider] used by the SDK. pub fn context_provider(&self) -> Option { let provider_guard = self.context_provider.load(); @@ -2429,6 +2495,89 @@ pub(crate) mod test { .expect("register epoch refresh expectation"); } + /// What a proof that only protocol version 14 or later reads holds: a stand-in for a + /// contract written with grammar protocol version 13 can not parse, its response reporting + /// version 14. It records the version it was parsed under. + struct ReadableFromVersion14(u32); + + #[derive(Debug, Clone)] + struct ProbeRequest; + + #[derive(Debug, Clone)] + struct ProbeResponse; + + impl drive_proof_verifier::FromProof for ReadableFromVersion14 { + type Request = ProbeRequest; + type Response = ProbeResponse; + + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( + _request: I, + _response: O, + _network: Network, + platform_version: &dpp::version::PlatformVersion, + _provider: &'a dyn dash_context_provider::ContextProvider, + ) -> Result< + ( + Option, + ResponseMetadata, + dapi_grpc::platform::v0::Proof, + ), + drive_proof_verifier::Error, + > + where + Self: Sized + 'a, + { + if platform_version.protocol_version < 14 { + return Err(drive_proof_verifier::Error::ResponseDecodeError { + error: "unsupported property type: bytes".to_string(), + }); + } + Ok(( + Some(ReadableFromVersion14(platform_version.protocol_version)), + ResponseMetadata { + protocol_version: 14, + ..Default::default() + }, + Default::default(), + )) + } + } + + fn parse_probe(sdk: &super::Sdk) -> Result { + let provider = sdk.context_provider().expect("the mock SDK has a provider"); + sdk.parse_proof_at_the_reported_version::( + ProbeRequest, + ProbeResponse, + &provider, + ) + .map(|(object, _, _)| object.expect("the probe holds an object").0) + } + + /// A fresh SDK at protocol version 13 reads a proof only version 14 parses: under the + /// newest version it knows, then under the version 14 the response reports + #[test] + fn should_parse_a_proof_its_version_can_not_read_at_the_version_the_response_reports() { + let sdk = SdkBuilder::new_mock() + .with_initial_version( + dpp::version::PlatformVersion::get(13).expect("protocol version 13"), + ) + .build() + .expect("the mock SDK builds"); + + assert_eq!(parse_probe(&sdk).expect("the proof parses"), 14); + } + + /// A pinned SDK keeps its version, failure included + #[test] + fn should_report_the_failure_of_a_pinned_sdk() { + let sdk = SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::get(13).expect("protocol version 13")) + .build() + .expect("the mock SDK builds"); + + assert!(parse_probe(&sdk).is_err()); + } + /// Seeded below `LATEST_VERSION`, a proven refresh ratchets the SDK up to the /// network's version through the *verified* metadata path (the mock injects /// `LATEST_VERSION` into the proven response's metadata, exactly as a real From 521bfa2fdb8e237b1cce80ee88009309c934fb45 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 00:38:34 +0300 Subject: [PATCH 17/62] test(sdk): cover the protocol version fallback through the quorum key verification `parse_proof_at_the_reported_version` borrows the request and response and copies them once per parse, so `verify_fetching_quorum_key` keeps one copy for both of its verifications instead of copying it again before the first. The probe is now scripted by its request (the first version that decodes it, the version its response reports, the versions whose signature or key fails) and records every version it is parsed under. New tests: - a reported version between the SDK's and the newest is parsed again under it, and its failure there is reported; - a signature failure under the newest version is reported over the first decoding error, and one under the SDK's version is not retried; - through `verify_fetching_quorum_key`, a fresh SDK reads the proof and learns the reported version, and learns none when the signature fails. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/sdk.rs | 299 +++++++++++++++++++++----- packages/rs-sdk/src/sdk/quorum_key.rs | 17 +- 2 files changed, 251 insertions(+), 65 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 419b988b7a4..15fb58a831f 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -604,10 +604,13 @@ impl Sdk { /// version would read it no differently, and under the newest one it is the closer account /// of the response. A missing key ([`drive_proof_verifier::Error::QuorumKeyUnavailable`]) /// thus reaches [`Self::verify_fetching_quorum_key`], which fetches it and calls this again. + /// + /// The request and response are borrowed and copied once for each parse, so the caller's + /// copies serve a later call too. fn parse_proof_at_the_reported_version>( &self, - request: O::Request, - response: O::Response, + request: &O::Request, + response: &O::Response, provider: &dyn ContextProvider, current: &PlatformVersion, ) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> @@ -655,8 +658,8 @@ impl Sdk { "parsed a proof under the protocol version its response reports" ); O::maybe_from_proof_with_metadata( - request, - response, + request.clone(), + response.clone(), self.network, reported, provider, @@ -2573,27 +2576,62 @@ pub(crate) mod test { .expect("register epoch refresh expectation"); } - /// What a proof that only protocol version 14 or later reads holds: a stand-in for a - /// contract written with grammar protocol version 13 can not parse, its response reporting - /// version 14. It records the version it was parsed under. Without `KEY_CACHED`, the - /// provider lacks the key of the quorum that signed it, which a proof read is only found to - /// need once it decodes. - struct ReadableFromVersion14(u32); + /// How the scripted proof fails once it was read under a protocol version. + #[derive(Debug, Clone, Copy)] + enum ProbeFailure { + /// The provider lacks the key of the quorum that signed it + MissingKey, + /// The quorum signature does not verify + InvalidSignature, + } + /// The request of a stand-in for a proof holding what only some protocol versions read, + /// such as a contract written with grammar protocol version 13 can not parse. It scripts + /// the proof: under a version below `decodes_from` it fails to decode, under one listed in + /// `fails_after_reading` it fails at the quorum signature or its key, and otherwise it holds + /// the version it was parsed under ([`ReadableFrom`]), its response reporting `reports`. + /// Every version it is parsed under is recorded in `attempts`, which its copies share. #[derive(Debug, Clone)] - struct ProbeRequest; + struct ProbeRequest { + decodes_from: u32, + reports: u32, + fails_after_reading: Vec<(u32, ProbeFailure)>, + attempts: Arc>>, + } + + impl ProbeRequest { + fn new(decodes_from: u32, reports: u32) -> Self { + Self { + decodes_from, + reports, + fails_after_reading: Vec::new(), + attempts: Default::default(), + } + } + + fn failing_after_reading(mut self, version: u32, failure: ProbeFailure) -> Self { + self.fails_after_reading.push((version, failure)); + self + } + + fn attempts(&self) -> Vec { + self.attempts.lock().expect("the attempts lock").clone() + } + } #[derive(Debug, Clone)] struct ProbeResponse; - impl drive_proof_verifier::FromProof - for ReadableFromVersion14 - { + /// What the scripted proof holds: the protocol version it was parsed under. + #[derive(Debug)] + struct ReadableFrom(u32); + + impl drive_proof_verifier::FromProof for ReadableFrom { type Request = ProbeRequest; type Response = ProbeResponse; fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( - _request: I, + request: I, _response: O, _network: Network, platform_version: &dpp::version::PlatformVersion, @@ -2609,85 +2647,236 @@ pub(crate) mod test { where Self: Sized + 'a, { - if platform_version.protocol_version < 14 { + let request: ProbeRequest = request.into(); + let version = platform_version.protocol_version; + request + .attempts + .lock() + .expect("the attempts lock") + .push(version); + if version < request.decodes_from { return Err(drive_proof_verifier::Error::ResponseDecodeError { error: "unsupported property type: bytes".to_string(), }); } - if !KEY_CACHED { - return Err(drive_proof_verifier::Error::QuorumKeyUnavailable { - quorum_type: 4, - quorum_hash: [7; 32], - core_chain_locked_height: 1, - error: dash_context_provider::ContextProviderError::InvalidQuorum( - "not cached".to_string(), - ), - }); + let failure = request + .fails_after_reading + .iter() + .find_map(|(at, failure)| (*at == version).then_some(*failure)); + match failure { + Some(ProbeFailure::MissingKey) => { + Err(drive_proof_verifier::Error::QuorumKeyUnavailable { + quorum_type: 4, + quorum_hash: [7; 32], + core_chain_locked_height: 1, + error: dash_context_provider::ContextProviderError::InvalidQuorum( + "not cached".to_string(), + ), + }) + } + Some(ProbeFailure::InvalidSignature) => { + Err(drive_proof_verifier::Error::InvalidSignature { + error: "the quorum signature does not verify".to_string(), + }) + } + None => Ok(( + Some(ReadableFrom(version)), + ResponseMetadata { + height: 1, + time_ms: chrono::Utc::now().timestamp_millis() as u64, + protocol_version: request.reports, + ..Default::default() + }, + Default::default(), + )), } - Ok(( - Some(ReadableFromVersion14(platform_version.protocol_version)), - ResponseMetadata { - protocol_version: 14, - ..Default::default() - }, - Default::default(), - )) } } - fn parse_probe( + /// The newest protocol version this SDK knows. + fn latest() -> u32 { + dpp::version::PlatformVersion::latest().protocol_version + } + + /// A mock SDK that learns its version from the network, starting at `version`. + fn sdk_seeded_at(version: u32) -> super::Sdk { + SdkBuilder::new_mock() + .with_initial_version( + dpp::version::PlatformVersion::get(version).expect("a known protocol version"), + ) + .build() + .expect("the mock SDK builds") + } + + /// The scripted proof read as the version fallback reads it, from the SDK's version: the + /// version it was read under. + fn parse_probe( sdk: &super::Sdk, + request: &ProbeRequest, ) -> Result { let provider = sdk.context_provider().expect("the mock SDK has a provider"); - sdk.parse_proof_at_the_reported_version::>( - ProbeRequest, - ProbeResponse, + sdk.parse_proof_at_the_reported_version::( + request, + &ProbeResponse, &provider, sdk.version(), ) .map(|(object, _, _)| object.expect("the probe holds an object").0) } - /// A fresh SDK at protocol version 13 reads a proof only version 14 parses: under the - /// newest version it knows, then under the version 14 the response reports + /// A fresh SDK reads a proof only the newest version parses: under the newest version it + /// knows, which the response reports #[test] fn should_parse_a_proof_its_version_can_not_read_at_the_version_the_response_reports() { - let sdk = SdkBuilder::new_mock() - .with_initial_version( - dpp::version::PlatformVersion::get(13).expect("protocol version 13"), - ) - .build() - .expect("the mock SDK builds"); + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let request = ProbeRequest::new(latest, latest); + + assert_eq!( + parse_probe(&sdk, &request).expect("the proof parses"), + latest + ); + assert_eq!(request.attempts(), [latest - 1, latest]); + } - assert_eq!(parse_probe::(&sdk).expect("the proof parses"), 14); + /// A response reporting a version between the SDK's and the newest is read again under it + #[test] + fn should_parse_again_under_a_reported_version_older_than_the_newest() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 2); + let request = ProbeRequest::new(latest - 1, latest - 1); + + assert_eq!( + parse_probe(&sdk, &request).expect("the proof parses"), + latest - 1 + ); + assert_eq!(request.attempts(), [latest - 2, latest, latest - 1]); + } + + /// The parse under the reported version decides: its failure is reported + #[test] + fn should_report_a_failure_of_the_parse_under_the_reported_version() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 2); + let request = ProbeRequest::new(latest - 1, latest - 1) + .failing_after_reading(latest - 1, ProbeFailure::InvalidSignature); + + assert!(matches!( + parse_probe(&sdk, &request), + Err(drive_proof_verifier::Error::InvalidSignature { .. }) + )); + assert_eq!(request.attempts(), [latest - 2, latest, latest - 1]); + } + + /// A proof the newest version reads but whose signature fails reports that failure, not the + /// decoding error of the SDK's version + #[test] + fn should_report_a_signature_failure_under_the_newest_version_over_the_decoding_error() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let request = ProbeRequest::new(latest, latest) + .failing_after_reading(latest, ProbeFailure::InvalidSignature); + + assert!(matches!( + parse_probe(&sdk, &request), + Err(drive_proof_verifier::Error::InvalidSignature { .. }) + )); } /// A proof that decodes only under the newest version but whose quorum key is not cached /// reports the missing key, so the caller fetches it and reads the proof again #[test] fn should_report_a_missing_quorum_key_found_under_the_newest_version() { - let sdk = SdkBuilder::new_mock() - .with_initial_version( - dpp::version::PlatformVersion::get(13).expect("protocol version 13"), - ) - .build() - .expect("the mock SDK builds"); + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let request = ProbeRequest::new(latest, latest) + .failing_after_reading(latest, ProbeFailure::MissingKey); assert!(matches!( - parse_probe::(&sdk), + parse_probe(&sdk, &request), Err(drive_proof_verifier::Error::QuorumKeyUnavailable { .. }) )); } + /// A proof read under the SDK's version whose signature fails is not read under another + #[test] + fn should_not_try_another_version_after_a_signature_failure() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let request = ProbeRequest::new(latest - 1, latest) + .failing_after_reading(latest - 1, ProbeFailure::InvalidSignature); + + assert!(matches!( + parse_probe(&sdk, &request), + Err(drive_proof_verifier::Error::InvalidSignature { .. }) + )); + assert_eq!(request.attempts(), [latest - 1]); + } + /// A pinned SDK keeps its version, failure included #[test] fn should_report_the_failure_of_a_pinned_sdk() { + let latest = latest(); let sdk = SdkBuilder::new_mock() - .with_version(dpp::version::PlatformVersion::get(13).expect("protocol version 13")) + .with_version( + dpp::version::PlatformVersion::get(latest - 1).expect("a known protocol version"), + ) .build() .expect("the mock SDK builds"); + let request = ProbeRequest::new(latest, latest); + + assert!(matches!( + parse_probe(&sdk, &request), + Err(drive_proof_verifier::Error::ResponseDecodeError { .. }) + )); + assert_eq!(request.attempts(), [latest - 1]); + } + + /// The quorum-key verification every network response goes through reads a proof its + /// version can not under the version the response reports, and the SDK learns that version + #[tokio::test] + async fn should_learn_the_version_of_a_proof_read_through_the_quorum_key_verification() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let provider = sdk.context_provider().expect("the mock SDK has a provider"); + let request = ProbeRequest::new(latest, latest); + + let (object, metadata, _) = sdk + .verify_fetching_quorum_key::( + request.clone(), + ProbeResponse, + &provider, + "probe", + ) + .await + .expect("the proof is read under the newest version"); + + assert_eq!(object.expect("the probe holds an object").0, latest); + assert_eq!(metadata.protocol_version, latest); + assert_eq!(sdk.version().protocol_version, latest); + assert_eq!(request.attempts(), [latest - 1, latest]); + } + + /// A response whose signature fails under the version it is read under teaches the SDK no + /// version + #[tokio::test] + async fn should_not_learn_a_version_from_a_proof_whose_signature_fails() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 1); + let provider = sdk.context_provider().expect("the mock SDK has a provider"); + let request = ProbeRequest::new(latest, latest) + .failing_after_reading(latest, ProbeFailure::InvalidSignature); + + sdk.verify_fetching_quorum_key::( + request, + ProbeResponse, + &provider, + "probe", + ) + .await + .expect_err("the signature fails"); - assert!(parse_probe::(&sdk).is_err()); + assert_eq!(sdk.version().protocol_version, latest - 1); } /// Seeded below `LATEST_VERSION`, a proven refresh ratchets the SDK up to the diff --git a/packages/rs-sdk/src/sdk/quorum_key.rs b/packages/rs-sdk/src/sdk/quorum_key.rs index 21edbb98260..4177f94e7bb 100644 --- a/packages/rs-sdk/src/sdk/quorum_key.rs +++ b/packages/rs-sdk/src/sdk/quorum_key.rs @@ -44,20 +44,17 @@ impl Sdk { { // Both verifications start from the same protocol version, each read // under the version the response reports when that one can not read it - // (`parse_proof_at_the_reported_version`). The request and response - // are copied on every call so a second verification can follow a - // fetch; the copy is small next to proof verification itself. + // (`parse_proof_at_the_reported_version`). Both borrow the request and + // response, which each parse copies, so a second verification can + // follow a fetch; the copy is small next to proof verification itself. let version = self.version(); let arrival = MetadataArrival { height: self.metadata_last_seen_height.load(Ordering::Acquire), time_ms: chrono::Utc::now().timestamp_millis() as u64, }; - let missing = match self.parse_proof_at_the_reported_version::( - request.clone(), - response.clone(), - provider, - version, - ) { + let missing = match self + .parse_proof_at_the_reported_version::(&request, &response, provider, version) + { Ok(verified) => { self.accept_verified_metadata(method_name, &verified.1, Some(arrival))?; return Ok(verified); @@ -91,7 +88,7 @@ impl Sdk { match fetch.await { Ok(Some(_)) => match self - .parse_proof_at_the_reported_version::(request, response, provider, version) + .parse_proof_at_the_reported_version::(&request, &response, provider, version) { // The provider broke its contract, for example by not caching // the key it returned. The node is not to blame for that. From 193c42f00b664daa7c6e68cfc85ce3aea5188608 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 02:24:44 +0300 Subject: [PATCH 18/62] test(sdk): fetch a missing quorum key and read the proof again through the version fallback The probe now asks the provider for its quorum's key instead of failing outright, so a key the provider fetches makes the second verification succeed. A new test drives `verify_fetching_quorum_key` with an SDK two versions back and a provider holding no key: one fetch, the parse sequence current, newest, current, newest, reported, and the SDK learns the reported version. A regression of the second verification to a direct parse at the SDK's version would fail it. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/sdk.rs | 211 +++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 57 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 15fb58a831f..03a1e76b7ef 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -2576,26 +2576,23 @@ pub(crate) mod test { .expect("register epoch refresh expectation"); } - /// How the scripted proof fails once it was read under a protocol version. - #[derive(Debug, Clone, Copy)] - enum ProbeFailure { - /// The provider lacks the key of the quorum that signed it - MissingKey, - /// The quorum signature does not verify - InvalidSignature, - } + /// The quorum the scripted proof names as its signer. + const PROBE_QUORUM: (u32, [u8; 32], u32) = (4, [7; 32], 1); /// The request of a stand-in for a proof holding what only some protocol versions read, /// such as a contract written with grammar protocol version 13 can not parse. It scripts - /// the proof: under a version below `decodes_from` it fails to decode, under one listed in - /// `fails_after_reading` it fails at the quorum signature or its key, and otherwise it holds - /// the version it was parsed under ([`ReadableFrom`]), its response reporting `reports`. - /// Every version it is parsed under is recorded in `attempts`, which its copies share. + /// the proof: under a version below `decodes_from` it fails to decode; once read, it asks + /// the provider for the key of [`PROBE_QUORUM`] when `key_needs_fetch`, failing while the + /// provider has none, and its quorum signature fails under the versions in + /// `signature_fails_at`; otherwise it holds the version it was parsed under + /// ([`ReadableFrom`]), its response reporting `reports`. Every version it is parsed under is + /// recorded in `attempts`, which its copies share. #[derive(Debug, Clone)] struct ProbeRequest { decodes_from: u32, reports: u32, - fails_after_reading: Vec<(u32, ProbeFailure)>, + key_needs_fetch: bool, + signature_fails_at: Vec, attempts: Arc>>, } @@ -2604,13 +2601,19 @@ pub(crate) mod test { Self { decodes_from, reports, - fails_after_reading: Vec::new(), + key_needs_fetch: false, + signature_fails_at: Vec::new(), attempts: Default::default(), } } - fn failing_after_reading(mut self, version: u32, failure: ProbeFailure) -> Self { - self.fails_after_reading.push((version, failure)); + fn needing_its_key_fetched(mut self) -> Self { + self.key_needs_fetch = true; + self + } + + fn with_signature_failing_at(mut self, version: u32) -> Self { + self.signature_fails_at.push(version); self } @@ -2635,7 +2638,7 @@ pub(crate) mod test { _response: O, _network: Network, platform_version: &dpp::version::PlatformVersion, - _provider: &'a dyn dash_context_provider::ContextProvider, + provider: &'a dyn dash_context_provider::ContextProvider, ) -> Result< ( Option, @@ -2659,37 +2662,102 @@ pub(crate) mod test { error: "unsupported property type: bytes".to_string(), }); } - let failure = request - .fails_after_reading - .iter() - .find_map(|(at, failure)| (*at == version).then_some(*failure)); - match failure { - Some(ProbeFailure::MissingKey) => { - Err(drive_proof_verifier::Error::QuorumKeyUnavailable { - quorum_type: 4, - quorum_hash: [7; 32], - core_chain_locked_height: 1, - error: dash_context_provider::ContextProviderError::InvalidQuorum( - "not cached".to_string(), - ), - }) + let (quorum_type, quorum_hash, core_chain_locked_height) = PROBE_QUORUM; + if request.key_needs_fetch { + if let Err(error) = provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ) { + return Err(drive_proof_verifier::Error::QuorumKeyUnavailable { + quorum_type, + quorum_hash, + core_chain_locked_height, + error, + }); } - Some(ProbeFailure::InvalidSignature) => { - Err(drive_proof_verifier::Error::InvalidSignature { - error: "the quorum signature does not verify".to_string(), - }) - } - None => Ok(( - Some(ReadableFrom(version)), - ResponseMetadata { - height: 1, - time_ms: chrono::Utc::now().timestamp_millis() as u64, - protocol_version: request.reports, - ..Default::default() - }, - Default::default(), - )), } + if request.signature_fails_at.contains(&version) { + return Err(drive_proof_verifier::Error::InvalidSignature { + error: "the quorum signature does not verify".to_string(), + }); + } + Ok(( + Some(ReadableFrom(version)), + ResponseMetadata { + height: 1, + time_ms: chrono::Utc::now().timestamp_millis() as u64, + protocol_version: request.reports, + ..Default::default() + }, + Default::default(), + )) + } + } + + /// A provider holding no quorum key until asked to fetch one, then holding it; it counts + /// the fetches. + #[derive(Default)] + struct KeyFetchedOnRequest { + fetched: std::sync::atomic::AtomicBool, + fetches: std::sync::atomic::AtomicUsize, + } + + impl dash_context_provider::ContextProvider for KeyFetchedOnRequest { + fn get_data_contract( + &self, + _id: &dpp::prelude::Identifier, + _platform_version: &dpp::version::PlatformVersion, + ) -> Result< + Option>, + dash_context_provider::ContextProviderError, + > { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &dpp::prelude::Identifier, + ) -> Result< + Option, + dash_context_provider::ContextProviderError, + > { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], dash_context_provider::ContextProviderError> { + if self.fetched.load(std::sync::atomic::Ordering::SeqCst) { + Ok([7; 48]) + } else { + Err(dash_context_provider::ContextProviderError::InvalidQuorum( + "not cached".to_string(), + )) + } + } + + fn fetch_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Option { + self.fetches + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.fetched + .store(true, std::sync::atomic::Ordering::SeqCst); + Some(Box::pin(async { Ok(Some([7; 48])) })) + } + + fn get_platform_activation_height( + &self, + ) -> Result + { + Ok(1) } } @@ -2714,7 +2782,8 @@ pub(crate) mod test { sdk: &super::Sdk, request: &ProbeRequest, ) -> Result { - let provider = sdk.context_provider().expect("the mock SDK has a provider"); + // A provider holding no quorum key, which it is never asked to fetch here + let provider = KeyFetchedOnRequest::default(); sdk.parse_proof_at_the_reported_version::( request, &ProbeResponse, @@ -2758,8 +2827,8 @@ pub(crate) mod test { fn should_report_a_failure_of_the_parse_under_the_reported_version() { let latest = latest(); let sdk = sdk_seeded_at(latest - 2); - let request = ProbeRequest::new(latest - 1, latest - 1) - .failing_after_reading(latest - 1, ProbeFailure::InvalidSignature); + let request = + ProbeRequest::new(latest - 1, latest - 1).with_signature_failing_at(latest - 1); assert!(matches!( parse_probe(&sdk, &request), @@ -2774,8 +2843,7 @@ pub(crate) mod test { fn should_report_a_signature_failure_under_the_newest_version_over_the_decoding_error() { let latest = latest(); let sdk = sdk_seeded_at(latest - 1); - let request = ProbeRequest::new(latest, latest) - .failing_after_reading(latest, ProbeFailure::InvalidSignature); + let request = ProbeRequest::new(latest, latest).with_signature_failing_at(latest); assert!(matches!( parse_probe(&sdk, &request), @@ -2789,8 +2857,7 @@ pub(crate) mod test { fn should_report_a_missing_quorum_key_found_under_the_newest_version() { let latest = latest(); let sdk = sdk_seeded_at(latest - 1); - let request = ProbeRequest::new(latest, latest) - .failing_after_reading(latest, ProbeFailure::MissingKey); + let request = ProbeRequest::new(latest, latest).needing_its_key_fetched(); assert!(matches!( parse_probe(&sdk, &request), @@ -2798,13 +2865,44 @@ pub(crate) mod test { )); } + /// The quorum-key verification fetches the key a proof only a newer version reads is + /// signed with, and reads it again through the version fallback: under the SDK's version, + /// the newest and the one the response reports, which the SDK then learns + #[tokio::test] + async fn should_fetch_a_missing_key_and_read_the_proof_again_through_the_version_fallback() { + let latest = latest(); + let sdk = sdk_seeded_at(latest - 2); + let provider = KeyFetchedOnRequest::default(); + let request = ProbeRequest::new(latest - 1, latest - 1).needing_its_key_fetched(); + + let (object, _, _) = sdk + .verify_fetching_quorum_key::( + request.clone(), + ProbeResponse, + &provider, + "probe", + ) + .await + .expect("the proof is read once its key is fetched"); + + assert_eq!(object.expect("the probe holds an object").0, latest - 1); + assert_eq!( + provider.fetches.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + assert_eq!( + request.attempts(), + [latest - 2, latest, latest - 2, latest, latest - 1] + ); + assert_eq!(sdk.version().protocol_version, latest - 1); + } + /// A proof read under the SDK's version whose signature fails is not read under another #[test] fn should_not_try_another_version_after_a_signature_failure() { let latest = latest(); let sdk = sdk_seeded_at(latest - 1); - let request = ProbeRequest::new(latest - 1, latest) - .failing_after_reading(latest - 1, ProbeFailure::InvalidSignature); + let request = ProbeRequest::new(latest - 1, latest).with_signature_failing_at(latest - 1); assert!(matches!( parse_probe(&sdk, &request), @@ -2864,8 +2962,7 @@ pub(crate) mod test { let latest = latest(); let sdk = sdk_seeded_at(latest - 1); let provider = sdk.context_provider().expect("the mock SDK has a provider"); - let request = ProbeRequest::new(latest, latest) - .failing_after_reading(latest, ProbeFailure::InvalidSignature); + let request = ProbeRequest::new(latest, latest).with_signature_failing_at(latest); sdk.verify_fetching_quorum_key::( request, From 759d6795ac0a596c9ba7637b405c3e168f985002 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 14:20:26 +0300 Subject: [PATCH 19/62] feat(platform)!: dpns v3 preorders hash the owner and the name, without a salt A DPNS v3 preorder now holds `ownerNameHash`, the sha256d of the owner's id, the normalized label, "." and the normalized parent, unique per owner (`ownerAndNameHash`), and `nameHash`, the same without the owner, indexed with its creation height (`nameHashAndHeight`). It carries no salt and lives an hour (`ttl` 3600, so it refunds nothing when consumed). The domain finds it through `creatorRefersTo` (findBy `$ownerId` and the `ownerNameHash` function, minimumAgeBlocks 1, consume). `preorderSalt` stays declared on the domain, transient and no longer required, so domains stored under v1 and v2 decode. `nameHash` is there for a first-committer refersTo keyword built in a separate PR: with it, a name whose older valid preorder belongs to someone else is refused, which stops a watcher who reads a pending name from the mempool from preordering and revealing it first under full blocks. No salted design can check that before the owner reveals. Without a salt an owner holds one pending preorder of a name: a second is refused as a duplicate (40105) while the first stands. `Sdk::register_dpns_name` reveals a pending one instead of preordering again. - Drive: `apply_contract_rebuilding_document_types` leaves a type already stored as declared (so the v14 upgrade stays idempotent), and rebuilds into a type with a ttl, which holds no document and so no expiry entry; a stored type with a ttl is still refused. - rs-sdk and js-dash-sdk build the preorder from the domain's creator reference (`nameHash` from its params without `$ownerId`) and send no salt from v3; v1 and v2 keep the salted hash. - Tests: dpp DPNS v3 shape and hash; drive rebuild (ttl new type, type left as stored, stored ttl refused); drive-abci dpns_v3 (one pending preorder per name, the contested retry reveals the same preorder, preorder and domain fees pinned: 62,554,803 credits a name at 14 against 76,862,280 at 13); the check_tx update pin gains the 0.01 Dash non-unique index; rs-sdk and js-dash-sdk preorder shapes. - Docs: v14 note 90, the book (commit and reveal, documents, deletion, transient). Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/deletion.md | 12 +- .../src/contract-keywords/refers-to-lookup.md | 41 ++- book/src/contract-keywords/transient.md | 6 +- book/src/data-model/documents.md | 52 ++- .../schema/v3/dpns-contract-documents.json | 74 +++-- packages/dpns-contract/src/v3/mod.rs | 42 ++- .../Platform/methods/names/register.spec.ts | 55 ++-- .../Client/Platform/methods/names/register.ts | 48 +-- packages/rs-dpp/src/system_data_contracts.rs | 137 ++++++++ .../src/execution/check_tx/v0/mod.rs | 5 +- .../data_triggers/triggers/dpns/v2/mod.rs | 6 +- .../batch/tests/document/creation.rs | 128 ++++---- .../batch/tests/document/dpns.rs | 10 +- .../batch/tests/document/dpns_v3.rs | 223 +++++++++---- .../tests/document/owner_balance_proof.rs | 19 +- .../state_transition/state_transitions/mod.rs | 26 +- .../rs-drive-abci/src/test/helpers/dpns.rs | 59 +++- .../strategy_tests/test_cases/voting_tests.rs | 10 +- ...pply_contract_rebuilding_document_types.rs | 130 +++++++- .../rs-platform-version/src/version/v14.rs | 24 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 306 ++++++++++++------ .../functional/transitions/documents.spec.ts | 13 +- 22 files changed, 964 insertions(+), 462 deletions(-) diff --git a/book/src/contract-keywords/deletion.md b/book/src/contract-keywords/deletion.md index eb64fdb7b75..bffaf422bd7 100644 --- a/book/src/contract-keywords/deletion.md +++ b/book/src/contract-keywords/deletion.md @@ -67,22 +67,20 @@ A commenter may take a comment down at any time. Since `true` is the usual defau "canBeDeleted": "onlyWhenConsumed", "indices": [ { - "name": "ownerAndSaltedHash", - "properties": [{ "$ownerId": "asc" }, { "saltedDomainHash": "asc" }], + "name": "ownerAndNameHash", + "properties": [{ "$ownerId": "asc" }, { "ownerNameHash": "asc" }], "unique": true } ], "properties": { - "saltedDomainHash": { - "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0 - } + "ownerNameHash": { "type": "bytes", "size": 32, "position": 0 } }, - "required": ["$createdAtBlockHeight", "saltedDomainHash"], + "required": ["$createdAtBlockHeight", "ownerNameHash"], "additionalProperties": false } ``` -A preorder of this type stays until the name registration that reveals it consumes it (the `preorderSalt` declaration in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. DPNS v3's own `preorder` declares the same index but `canBeDeleted: true`, so its owner may still delete a preorder it no longer wants; this example is the stricter variant. +A preorder of this type stays until the name registration that reveals it consumes it (the domain's `creatorRefersTo` in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. DPNS v3's own `preorder` declares the same index but `canBeDeleted: true`, so its owner may still delete a preorder it no longer wants, and a `ttl` of an hour, after which the platform deletes it; this example is the stricter variant. - A delete transition of such a document is refused (`InvalidDocumentTransitionActionError`, 10404), as for `false`. - A consume deletes it as a delete by its owner would, its storage refunded to the owner. diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index 9e12efbc667..347a8e8ad2b 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -138,39 +138,36 @@ The value of a reference found by `findBy` is a key part, not a document id. So ## Commit and reveal -One `findBy` entry may hold a function: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds the SHA-256 of the SHA-256 of the params' bytes, joined in order. The platform fills that property of the key with the hash to find the document, like any other entry. The document it finds is a commitment made earlier, one that stored that hash, so the document being created may exist only while a commitment to values it carries exists. This is how the DPNS `domain` type registers a name from protocol version 14 (DPNS contract v3), a declaration on the salt: +One `findBy` entry may hold a function: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds the SHA-256 of the SHA-256 of the params' bytes, joined in order. The platform fills that property of the key with the hash to find the document, like any other entry. The document it finds is a commitment made earlier, one that stored that hash, so the document being created may exist only while a commitment to values it carries exists. This is how the DPNS `domain` type registers a name from protocol version 14 (DPNS contract v3), a declaration on the domain's creator: ```json -"preorderSalt": { - "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 4, - "refersTo": { - "type": "deletableDocument", - "documentType": "preorder", - "findBy": { - "$ownerId": "$ownerId", - "saltedDomainHash": { - "function": "sys.hash.sha256d", - "params": ["$ownerId", "preorderSalt", "normalizedLabel", { "const": "." }, "parentDomainName"] - } - }, - "minimumAgeBlocks": 1, - "consume": true - } +"creatorRefersTo": { + "type": "deletableDocument", + "documentType": "preorder", + "findBy": { + "$ownerId": "$ownerId", + "ownerNameHash": { + "function": "sys.hash.sha256d", + "params": ["$ownerId", "normalizedLabel", { "const": "." }, "normalizedParentDomainName"] + } + }, + "minimumAgeBlocks": 1, + "consume": true } ``` -The salt must reveal the writer's own `preorder` whose `saltedDomainHash` is the sha256d of the writer's id, the salt, the normalized label, a dot and the parent. `findBy` names `$ownerId` and `saltedDomainHash`, the whole of the `preorder` type's unique `ownerAndSaltedHash` index, so every identity has its own preorders and a copy of someone else's never collides with theirs. `minimumAgeBlocks: 1` asks for one created in an earlier block, and `consume: true` deletes it with the create. As SQL: +The creator must hold a `preorder` of the name, whose `ownerNameHash` is the sha256d of the creator's id, the normalized label, a dot and the normalized parent. `findBy` names `$ownerId` and `ownerNameHash`, the whole of the `preorder` type's unique `ownerAndNameHash` index, so every identity has its own preorders, a copy of someone else's never collides with theirs, and an identity holds one pending preorder of a name. `minimumAgeBlocks: 1` asks for one created in an earlier block, and `consume: true` deletes it with the create. As SQL: ```sql SELECT * FROM preorder -WHERE $ownerId = :writer -- findBy - AND saltedDomainHash = -- findBy - sha256d(:writer || :preorderSalt || :normalizedLabel || '.' || :parentDomainName) +WHERE $ownerId = :creator -- findBy + AND ownerNameHash = -- findBy + sha256d(:creator || :normalizedLabel || '.' || :normalizedParentDomainName) AND $createdAtBlockHeight <= :createHeight - 1 -- minimumAgeBlocks -- then the create deletes the row found -- consume ``` -Hashing the writer matters as much as keying by it. A copy of a pending preorder, stored under the copier's own key, commits to the identity that made it, so the copier, who learns the salt from that identity's reveal, still finds nothing when revealing the same name. +Hashing the creator matters as much as keying by it. A copy of a pending preorder, stored under the copier's own key, commits to the identity that made it, so the copier, who learns the name from that identity's reveal, still finds nothing when revealing it. The params hold no salt: the hash commits to the name itself. A DPNS preorder also holds `nameHash`, the same hash without the creator, indexed with its creation height, and lives an hour (`ttl`). A contract that wants the name hidden until the reveal puts a salt, a transient property of the referring type, among the params instead. - **Params.** A property path reads the document being created, the property carrying the reference included; `{ "const": text }` is fixed text of 1 to 64 bytes; `"."` is the value carrying the reference where it has no path (each element of a typed array, the writer, the creator); `"$ownerId"` is the writer's id; 1 to 16 params. Strings count as their UTF-8, byte arrays as their bytes, identifiers as their 32 bytes. Two values of no fixed length must be separated by a one-byte `const`, and a value holding that byte is refused, so the joined bytes split back one way only. - **One function.** `findBy` holds at most one function, and the property it fills must be a byte array of exactly 32 bytes. @@ -180,7 +177,7 @@ Hashing the writer matters as much as keying by it. A copy of a pending preorder `minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds when the referring document is created, so the reference must be judged on the create alone. A function in `findBy` makes it so, and so does a referring type whose documents are never replaced (`documentsMutable: false`): a replace is the one write that checks a reference again. Any other `findBy` declaring either is refused at registration (10231), and so is either beside [`inList`](refers-to-list-element.md), which checks that a list holds the value rather than finding a document by a key. - **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. -- **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference that makes the writer the commitment's owner, by the `where` entry `"$ownerId": "$ownerId"` or the `findBy` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). +- **`consume: true`**: the create deletes the commitment, its storage refunded to its owner (a commitment type with a [`ttl`](ttl.md) refunds nothing, as DPNS v3's preorder). Only on a `deletableDocument` reference that makes the writer the commitment's owner, by the `where` entry `"$ownerId": "$ownerId"` or the `findBy` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). A create missing a param, or with a value holding its separator, is refused before any read (`DocumentReferencePreimageInvalidError`, 10423). No commitment is `ReferencedEntityNotFoundError` (40120), another identity's `ReferencedDocumentPropertyMismatchError` (40127) with the owner entry in `where` and 40120 with it in `findBy` (as in DPNS, whose key then finds none of the writer's), and one too young `ReferencedDocumentRequirementNotMetError` (40142). The [Documents chapter](../data-model/documents.md#commit-and-reveal-a-findby-function) has the full rules. diff --git a/book/src/contract-keywords/transient.md b/book/src/contract-keywords/transient.md index a8a883cf32a..934c2c1636a 100644 --- a/book/src/contract-keywords/transient.md +++ b/book/src/contract-keywords/transient.md @@ -13,7 +13,7 @@ ## Example -The DPNS `domain` type, trimmed to two properties: +The DPNS `domain` type of contracts v1 and v2, trimmed to two properties: ```json "domain": { @@ -36,11 +36,11 @@ The DPNS `domain` type, trimmed to two properties: } ``` -Registering a name takes two steps. A `preorder` document first commits to a hash of the salt and the name, and the `domain` document then reveals both. Every domain create must carry its 32-byte `preorderSalt`, and DPNS checks it against the preorder: from protocol version 14 through a `refersTo` on the salt that finds the writer's preorder and deletes it ([Commit and reveal](refers-to-lookup.md#commit-and-reveal)), before it in the type's create data trigger. Once the domain is created the salt has done its job, and the stored domain does not hold it. +Registering a name took two steps. A `preorder` document first committed to a hash of the salt and the name, and the `domain` document then revealed both. Every domain create carried its 32-byte `preorderSalt`, which the type's create data trigger checked against the preorder. Once the domain was created the salt had done its job, and the stored domain does not hold it. DPNS v3 (protocol version 14) finds the preorder by the hash of the creator and the name instead ([Commit and reveal](refers-to-lookup.md#commit-and-reveal)), and keeps `preorderSalt` declared, transient and unused, so domains stored under v1 and v2 decode. ## How it works -- **Checked like any other value.** A transient value is on the transition when the document is validated, so the schema holds it to its rules: a required transient property must be present, and its bounds apply. `maxBytes` and `distinctFrom` apply to it too, and so do checks such as the DPNS one above. +- **Checked like any other value.** A transient value is on the transition when the document is validated, so the schema holds it to its rules: a required transient property must be present, and its bounds apply. `maxBytes` and `distinctFrom` apply to it too, and so do checks such as the DPNS v1 and v2 one above. - **Dropped before storing.** After validation, a create drops the transient values before the document is written. From protocol version 14 a replace drops them the same way. Before 14 a replace stored the values it carried, so a replaced document could hold values its create had dropped. - **Dropped by top-level name.** Only top-level properties can be transient. When a transient property is an object, the whole object is dropped with everything in it. - **Never stored, never found.** No stored document holds a transient value, so a query cannot find one and a later reader cannot see it. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 7de6d489308..507d43675d7 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -511,53 +511,51 @@ A document type whose documents can be transferred or traded declares `creatorRe ### Commit and reveal (a `findBy` function) -One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind: a `preorder` holds `saltedDomainHash` under a unique index. Before protocol version 14 the `domain` create trigger hashed what the domain reveals and looked for it; from 14, DPNS contract v3 declares it on the salt the domain reveals, its preorders unique per owner: +One `findBy` entry may hold a function instead of a source: `"": { "function": "sys.hash.sha256d", "params": [...] }` says the referenced document's property holds a hash of values the document being created reveals. The platform fills that property of the key with the hash to find the referenced document, so the function computes a key part. The document it finds is a commitment made earlier, so the reference is a commit and reveal: the document may be created only while a commitment to values it carries exists. The function is a system function, as in [`generatedFrom`](#generated-properties-generatedfrom), from the `sys.hash` namespace; `sys.hash.sha256d`, the SHA-256 of the SHA-256, is the one there is. DPNS name registration is a commit and reveal of this kind. Before protocol version 14 a `preorder` held `saltedDomainHash` under a unique index, and the `domain` create trigger hashed the salt and the name the domain revealed and looked for it; from 14, DPNS contract v3 declares it on the domain's creator, its preorders unique per owner and holding no salt: ```json "preorder": { "type": "object", "documentsMutable": false, "canBeDeleted": true, + "ttl": 3600, "properties": { - "saltedDomainHash": { - "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0 - } + "ownerNameHash": { "type": "bytes", "size": 32, "position": 0 }, + "nameHash": { "type": "bytes", "size": 32, "position": 1 } }, "indices": [ { - "name": "ownerAndSaltedHash", - "properties": [{ "$ownerId": "asc" }, { "saltedDomainHash": "asc" }], + "name": "ownerAndNameHash", + "properties": [{ "$ownerId": "asc" }, { "ownerNameHash": "asc" }], "unique": true + }, + { + "name": "nameHashAndHeight", + "properties": [{ "nameHash": "asc" }, { "$createdAtBlockHeight": "asc" }] } ], - "required": ["$createdAtBlockHeight", "saltedDomainHash"], + "required": ["$createdAt", "$createdAtBlockHeight", "ownerNameHash", "nameHash"], "additionalProperties": false }, "domain": { "type": "object", - "properties": { - "preorderSalt": { - "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 4, - "refersTo": { - "type": "deletableDocument", - "documentType": "preorder", - "findBy": { - "$ownerId": "$ownerId", - "saltedDomainHash": { - "function": "sys.hash.sha256d", - "params": ["$ownerId", "preorderSalt", "normalizedLabel", { "const": "." }, "parentDomainName"] - } - }, - "minimumAgeBlocks": 1, - "consume": true + "creatorRefersTo": { + "type": "deletableDocument", + "documentType": "preorder", + "findBy": { + "$ownerId": "$ownerId", + "ownerNameHash": { + "function": "sys.hash.sha256d", + "params": ["$ownerId", "normalizedLabel", { "const": "." }, "normalizedParentDomainName"] } - } - }, - "transient": ["preorderSalt"] + }, + "minimumAgeBlocks": 1, + "consume": true + } } ``` -reads: the salt refers to the writer's own `preorder` (the `$ownerId` entry, the writer), found by its owner and `saltedDomainHash` (the whole of its unique `ownerAndSaltedHash` index), created in an earlier block, whose `saltedDomainHash` is the sha256d of the writer's id, the salt, the normalized label, a dot and the parent; creating the domain deletes that preorder. Keying by the owner keeps a copy of someone's preorder from colliding with theirs, and hashing the owner keeps the copier from revealing the name with it once the salt is public. The trigger before protocol version 14 hashed the salt with the normalized label, a dot and the parent, or with the label as sent for a top-level name; the preorders made before it are deleted when the version activates. On an index of several properties, `findBy` maps the others beside the function entry as usual. +reads: the domain refers to its creator's own `preorder` (the `$ownerId` entry, the writer, who creates it), found by its owner and `ownerNameHash` (the whole of its unique `ownerAndNameHash` index), created in an earlier block, whose `ownerNameHash` is the sha256d of the creator's id, the normalized label, a dot and the normalized parent; creating the domain deletes that preorder. Keying by the owner keeps a copy of someone's preorder from colliding with theirs, and hashing the owner keeps the copier from revealing the name with it once the reveal makes the name public. Without a salt an owner holds one pending preorder of a name: a second one shares its key and is refused (`DuplicateUniqueIndexError`), and the first reveals the name. The preorder also holds `nameHash`, the hash without the owner, and lives an hour (`ttl`), refunding nothing when consumed. The domain keeps `preorderSalt` declared, transient and unused, so domains stored under v1 and v2 decode. The trigger before protocol version 14 hashed the salt with the normalized label, a dot and the parent, or with the label as sent for a top-level name; the preorders made before it are deleted when the version activates. On an index of several properties, `findBy` maps the others beside the function entry as usual. A string or byte array property may carry a `refersTo` this way, or with its value read as `"."` (see [Found by a unique index](#found-by-a-unique-index-findby)): its value is not an id, so its declaration must be a `permanentDocument` or `deletableDocument` reference found by a `findBy` reading it, and the property keeps its type (the meta-schema admits a `refersTo` with `findBy` on a string or byte array). The value carrying the reference must fill the key exactly once: as a `"."` entry or as a param of the function. A param names a single property carrying the reference by its path, as the salt above; `"."` in the params stands only for a value without a path of its own: each element of a typed array, the writer of an `ownerRefersTo`, the creator of a `creatorRefersTo`. The writer and the creator may also be left out beside a function, since the declaration applies to every create. @@ -628,7 +626,7 @@ In Rust the properties frozen at creation are `DocumentTypeV2Getters::immutable_ ## Transient Properties -The doctype-level `transient` keyword lists top-level properties whose values are validated on the transition but never stored. DPNS uses it for the `domain`'s `preorderSalt`: the write proves the salted preorder, and the salt is then dropped. +The doctype-level `transient` keyword lists top-level properties whose values are validated on the transition but never stored. DPNS v1 and v2 use it for the `domain`'s `preorderSalt`: the write proves the salted preorder, and the salt is then dropped (v3 keeps the property declared and transient, unused). ```json "transient": ["preorderSalt"] diff --git a/packages/dpns-contract/schema/v3/dpns-contract-documents.json b/packages/dpns-contract/schema/v3/dpns-contract-documents.json index 6b42d5f7ec6..66ef162f472 100644 --- a/packages/dpns-contract/schema/v3/dpns-contract-documents.json +++ b/packages/dpns-contract/schema/v3/dpns-contract-documents.json @@ -41,6 +41,26 @@ ] } ], + "creatorRefersTo": { + "type": "deletableDocument", + "documentType": "preorder", + "findBy": { + "$ownerId": "$ownerId", + "ownerNameHash": { + "function": "sys.hash.sha256d", + "params": [ + "$ownerId", + "normalizedLabel", + { + "const": "." + }, + "normalizedParentDomainName" + ] + } + }, + "minimumAgeBlocks": 1, + "consume": true + }, "properties": { "label": { "type": "string", @@ -86,28 +106,7 @@ "type": "bytes", "size": 32, "position": 4, - "description": "Salt of the writer's preorder, made in an earlier block; the create deletes it", - "refersTo": { - "type": "deletableDocument", - "documentType": "preorder", - "findBy": { - "$ownerId": "$ownerId", - "saltedDomainHash": { - "function": "sys.hash.sha256d", - "params": [ - "$ownerId", - "preorderSalt", - "normalizedLabel", - { - "const": "." - }, - "parentDomainName" - ] - } - }, - "minimumAgeBlocks": 1, - "consume": true - } + "description": "Unused from v3; kept so domains written under v1 and v2 decode" }, "records": { "type": "object", @@ -146,7 +145,6 @@ "label", "normalizedLabel", "normalizedParentDomainName", - "preorderSalt", "records", "subdomainRules" ], @@ -176,32 +174,52 @@ "preorder": { "documentsMutable": false, "canBeDeleted": true, + "ttl": 3600, "type": "object", "indices": [ { - "name": "ownerAndSaltedHash", + "name": "ownerAndNameHash", "properties": [ { "$ownerId": "asc" }, { - "saltedDomainHash": "asc" + "ownerNameHash": "asc" } ], "unique": true + }, + { + "name": "nameHashAndHeight", + "properties": [ + { + "nameHash": "asc" + }, + { + "$createdAtBlockHeight": "asc" + } + ] } ], "properties": { - "saltedDomainHash": { + "ownerNameHash": { "type": "bytes", "size": 32, "position": 0, - "description": "sha256d of owner id, salt, normalized label, '.' and parent domain name" + "description": "sha256d of owner id, normalized label, '.' and normalized parent domain name" + }, + "nameHash": { + "type": "bytes", + "size": 32, + "position": 1, + "description": "sha256d of normalized label, '.' and normalized parent domain name" } }, "required": [ + "$createdAt", "$createdAtBlockHeight", - "saltedDomainHash" + "ownerNameHash", + "nameHash" ], "additionalProperties": false } diff --git a/packages/dpns-contract/src/v3/mod.rs b/packages/dpns-contract/src/v3/mod.rs index 150eb4472bd..3ba4fc11765 100644 --- a/packages/dpns-contract/src/v3/mod.rs +++ b/packages/dpns-contract/src/v3/mod.rs @@ -1,20 +1,36 @@ use crate::Error; use serde_json::Value; -/// The document type names and property names are unchanged from v1 (see -/// [`crate::v1::document_types`]), and so are every `domain` property's position and -/// the transient list, so domains written under v1 or v2 decode unchanged. v3 replaces -/// most of the `domain` create data trigger with schema keywords: `normalizedLabel` -/// and `normalizedParentDomainName` are generated from their counterparts -/// (`generatedFrom`), `preorderSalt` reveals the writer's own preorder from an -/// earlier block and deletes it (`refersTo` with a `findBy` over the writer and the -/// hash of the writer's id, the salt, the normalized label, "." and the parent, -/// `minimumAgeBlocks` and `consume`, which needs the preorder to record +/// The names v3 adds; every other name is v1's ([`crate::v1::document_types`]). +pub mod document_types { + pub mod preorder { + pub mod properties { + /// The double SHA-256 of the owner's id, the normalized label, "." and the + /// normalized parent domain name, which the domain's `creatorRefersTo` finds. + pub const OWNER_NAME_HASH: &str = "ownerNameHash"; + /// The same hash without the owner's id, shared by every preorder of a name. + pub const NAME_HASH: &str = "nameHash"; + } + } +} + +/// The document type names are unchanged from v1 (see [`crate::v1::document_types`]), +/// and so are every `domain` property's position and the transient list, so domains +/// written under v1 or v2 decode unchanged: `preorderSalt` stays declared, transient +/// and no longer required, but nothing reads it. v3 replaces most of the `domain` +/// create data trigger with schema keywords: `normalizedLabel` and +/// `normalizedParentDomainName` are generated from their counterparts +/// (`generatedFrom`), `creatorRefersTo` finds the creator's own preorder of the name +/// from an earlier block and deletes it (a `findBy` over the creator and the double +/// SHA-256 of the creator's id, the normalized label, "." and the normalized parent, +/// with `minimumAgeBlocks` and `consume`, which needs the preorder to record /// `$createdAtBlockHeight`), and the `recordsIdentityIsOwner` rule holds a new -/// domain's identity record to its owner. Preorders are unique per owner -/// (`ownerAndSaltedHash`), so the protocol upgrade deletes the v2 preorders and -/// rebuilds the type. Domains can no longer be deleted (`canBeDeleted: false`). The -/// parent domain checks stay in the create trigger. +/// domain's identity record to its owner. A preorder holds that hash +/// (`ownerNameHash`, unique per owner) and the same hash without the owner +/// (`nameHash`, indexed with the creation height, for telling the first preorder of +/// a name), and lives an hour (`ttl`). The protocol upgrade deletes the v2 preorders +/// and rebuilds the type. Domains can no longer be deleted (`canBeDeleted: false`). +/// The parent domain checks stay in the create trigger. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v3/dpns-contract-documents.json")) .map_err(Error::InvalidSchemaJson) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 523b165eb72..ba4a166f2dc 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -14,9 +14,9 @@ const sha256d = (data: Buffer): Buffer => { return sha256(sha256(data)); }; -// The `domain` schema of DPNS v3: the salt reveals the hash of the writer's id, -// the salt, the normalized label, '.' and the parent domain name, and a new -// name's records.identity is its owner +// The `domain` schema of DPNS v3: the creator reference finds the creator's own +// preorder by the hash of the creator's id, the normalized label, '.' and the +// normalized parent domain name, and a new name's records.identity is its owner const dpnsV3DomainSchema = () => ({ propertyConstraints: { recordsIdentityIsOwner: { @@ -26,18 +26,25 @@ const dpnsV3DomainSchema = () => ({ ], }, }, - properties: { - preorderSalt: { - refersTo: { - findBy: { - saltedDomainHash: { - function: 'sys.hash.sha256d', - params: ['$ownerId', 'preorderSalt', 'normalizedLabel', { const: '.' }, 'parentDomainName'], - }, - }, + creatorRefersTo: { + findBy: { + $ownerId: '$ownerId', + ownerNameHash: { + function: 'sys.hash.sha256d', + params: ['$ownerId', 'normalizedLabel', { const: '.' }, 'normalizedParentDomainName'], }, }, }, + properties: { + preorderSalt: {}, + }, +}); + +// The preorder DPNS v3 expects of `identityId` for 'user.dash': the hash of the +// creator and the name, and of the name alone +const dpnsV3Preorder = (identityId) => ({ + ownerNameHash: sha256d(Buffer.concat([identityId.toBuffer(), Buffer.from('user.dash')])), + nameHash: sha256d(Buffer.from('user.dash')), }); describe('Platform', () => { @@ -192,19 +199,15 @@ describe('Platform', () => { getDocumentSchema: dpnsV3DomainSchema, }); - await register.call(platformMock, 'User.dash', { + await register.call(platformMock, 'User.DASH', { identity: identityId, }, identityMock); - // sha256d(owner id ++ salt ++ 'user' ++ '.' ++ 'dash') - const expected = sha256d(Buffer.concat([ - identityId.toBuffer(), - Buffer.alloc(32), - Buffer.from('user.dash'), - ])); - expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.equal( - expected.toString('hex'), + // Of the normalized names, without a salt, which the domain no longer sends + expect(platformMock.documents.create.getCall(0).args[2]).to.deep.equal( + dpnsV3Preorder(identityId), ); + expect(platformMock.documents.create.getCall(1).args[2]).to.not.have.property('preorderSalt'); }); it('should hash with the DPNS contract the network stores, not a cached one', async () => { @@ -241,14 +244,8 @@ describe('Platform', () => { identity: identityId, }, identityMock); - // sha256d(owner id ++ salt ++ 'user' ++ '.' ++ 'dash') - const expected = sha256d(Buffer.concat([ - identityId.toBuffer(), - Buffer.alloc(32), - Buffer.from('user.dash'), - ])); - expect(platformMock.documents.create.getCall(0).args[2].saltedDomainHash.toString('hex')).to.equal( - expected.toString('hex'), + expect(platformMock.documents.create.getCall(0).args[2]).to.deep.equal( + dpnsV3Preorder(identityId), ); // The documents are created against the contract the hash was taken from expect(apps.get('dpns').contract).to.equal(storedContract); diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index a6d4a4a5456..777bec3d177 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -44,28 +44,30 @@ export async function register( const [label] = nameLabels; const normalizedLabel = convertToHomographSafeChars(label); - const preorderSalt = crypto.randomBytes(32); - const isSecondLevelDomain = normalizedParentDomainName.length > 0; if (!this.client.getApps().has('dpns')) { throw new Error('DPNS is required to register a new name.'); } - // The preorder commits to the hash the platform checks the domain's - // preorderSalt against. From DPNS v3 the contract declares it: the params of - // the salt's findBy function (the writer's id, the salt, the normalized label, - // '.' and the parent domain name as sent). Before it, the salt and - // `${normalizedLabel}.${parentDomainName}`, or the label as sent for a - // top-level name. The contract is the one the + // The preorder holds the hashes the platform finds it by. From DPNS v3 the + // contract declares them: the domain's creatorRefersTo finds the creator's own + // preorder by a findBy function over the creator's id, the normalized label, + // '.' and the normalized parent, which the preorder holds under the property + // the function fills, and the same params without the creator's id give its + // nameHash. Before it, the domain reveals a salt: the preorder holds + // saltedDomainHash, of the salt and `${normalizedLabel}.${parentDomainName}`, + // or the label as sent for a top-level name. The contract is the one the // network stores now, not one cached before an upgrade changed it, whose hash // the domain could not reveal; fetching it also replaces the cached one, so // both documents are created against it. const { contractId } = this.client.getApps().get('dpns'); const dpnsContract = await this.contracts.get(contractId, { skipCache: true }); const domainSchema = dpnsContract ? dpnsContract.getDocumentSchema('domain') : undefined; - const hashParams = domainSchema?.properties?.preorderSalt?.refersTo - ?.findBy?.saltedDomainHash?.params; + const [hashProperty, hashFunction] = Object.entries( + domainSchema?.creatorRefersTo?.findBy ?? {}, + ).find(([, source]: [string, any]) => Array.isArray(source?.params)) ?? []; + const hashParams: any[] | undefined = (hashFunction as any)?.params; // From DPNS v3 a new name's records.identity must be its owner // (recordsIdentityIsOwner): refuse another before the preorder is paid @@ -82,8 +84,6 @@ export async function register( switch (param) { case '$ownerId': return Buffer.from(identity.getId().toBuffer()); - case 'preorderSalt': - return preorderSalt; case 'label': return Buffer.from(label); case 'normalizedLabel': @@ -97,22 +97,26 @@ export async function register( } }; - const saltedDomainHash = hash( - Array.isArray(hashParams) - ? Buffer.concat(hashParams.map(paramBytes)) - : Buffer.concat([ + const preorderSalt = crypto.randomBytes(32); + const preorderProperties = Array.isArray(hashParams) + ? { + [hashProperty as string]: hash(Buffer.concat(hashParams.map(paramBytes))), + nameHash: hash(Buffer.concat( + hashParams.filter((param) => param !== '$ownerId').map(paramBytes), + )), + } + : { + saltedDomainHash: hash(Buffer.concat([ preorderSalt, Buffer.from(isSecondLevelDomain ? `${normalizedLabel}.${parentDomainName}` : label), - ]), - ); + ])), + }; // 1. Create preorder document const preorderDocument = await this.documents.create( 'dpns.preorder', identity, - { - saltedDomainHash, - }, + preorderProperties, ); await this.documents.broadcast( @@ -131,7 +135,7 @@ export async function register( normalizedLabel, parentDomainName, normalizedParentDomainName, - preorderSalt, + ...(Array.isArray(hashParams) ? {} : { preorderSalt }), records, subdomainRules: { allowSubdomains: !isSecondLevelDomain, diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index ede13a91d5f..4c1ca1930c6 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -1156,3 +1156,140 @@ mod moderation_charters_tests { } } } + +#[cfg(test)] +mod dpns_v3_tests { + use super::*; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; + use crate::data_contract::document_type::{ + DocumentReferenceKind, DocumentTypeRef, HashFunction, LookupHashKey, LookupKeyParam, + LookupKeySource, + }; + use crate::util::hash::hash_double; + use platform_value::{Identifier, Value}; + use std::collections::BTreeMap; + + /// DPNS v3, registered as the chain registers it, under full validation. + fn dpns() -> DataContract { + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS v3 registers under full validation") + } + + fn document_type<'a>(contract: &'a DataContract, name: &str) -> DocumentTypeRef<'a> { + contract + .document_type_for_name(name) + .unwrap_or_else(|_| panic!("the {name} type")) + } + + /// A domain finds its creator's own preorder by the creator's id and the hash of the + /// creator's id and the normalized name, made in an earlier block, and deletes it. The salt + /// stays declared, unused, so domains written under v1 and v2 decode + #[test] + fn should_find_the_creators_own_preorder_by_the_hash_of_the_creator_and_the_name() { + let contract = dpns(); + let domain = document_type(&contract, "domain"); + let declaration = domain + .creator_reference() + .and_then(|target| target.as_any_document_reference()) + .expect("the domain's creatorRefersTo is a document reference"); + assert_eq!(declaration.document_type_name, "preorder"); + assert_eq!(declaration.kind, DocumentReferenceKind::Deletable); + let lookup = declaration.lookup.expect("found by findBy"); + assert_eq!(lookup.keys.get("$ownerId"), Some(&LookupKeySource::OwnerId)); + assert_eq!( + lookup.keys.get("ownerNameHash"), + Some(&LookupKeySource::Hash(LookupHashKey { + function: HashFunction::Sha256d, + params: vec![ + LookupKeyParam::Writer, + LookupKeyParam::Property("normalizedLabel".to_string()), + LookupKeyParam::Const(".".to_string()), + LookupKeyParam::Property("normalizedParentDomainName".to_string()), + ], + })) + ); + assert_eq!(lookup.minimum_age_blocks, Some(1)); + assert!(lookup.consume); + + let salt = domain + .flattened_properties() + .get("preorderSalt") + .expect("preorderSalt stays declared"); + assert!(salt.transient); + assert!(salt.property_type.reference().is_none()); + assert!(salt.revealed_reference.is_none()); + assert!(!domain.required_fields().contains("preorderSalt")); + } + + /// The preorder of `b0b.dash` by an identity commits to the double SHA-256 of that + /// identity's id, the normalized label, "." and the normalized parent + #[test] + fn should_hash_the_creator_and_the_normalized_name() { + let contract = dpns(); + let domain = document_type(&contract, "domain"); + let key = domain + .creator_reference() + .and_then(|target| target.lookup_hash_key()) + .expect("the creator reference holds a findBy function"); + let creator = Identifier::from([3; 32]); + let data = BTreeMap::from([ + ( + "normalizedLabel".to_string(), + Value::Text("b0b".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ]); + + let (digest, _) = key + .key_value(domain, None, creator, &data) + .expect("the preimage assembles"); + + let mut preimage = creator.to_buffer().to_vec(); + preimage.extend(b"b0b.dash"); + assert_eq!(digest, hash_double(preimage)); + } + + /// A preorder lives an hour, is unique per owner and owner-and-name hash, and is + /// indexed by its name hash and creation height + #[test] + fn should_expire_preorders_after_an_hour_and_index_them_by_name_hash() { + let contract = dpns(); + let preorder = document_type(&contract, "preorder"); + assert_eq!(preorder.documents_ttl_seconds(), Some(3600)); + + let index_properties = |name: &str| { + let index = preorder + .indexes() + .get(name) + .unwrap_or_else(|| panic!("the {name} index")); + ( + index + .properties + .iter() + .map(|property| property.name.clone()) + .collect::>(), + index.unique, + ) + }; + assert_eq!( + index_properties("ownerAndNameHash"), + ( + vec!["$ownerId".to_string(), "ownerNameHash".to_string()], + true + ) + ); + assert_eq!( + index_properties("nameHashAndHeight"), + ( + vec!["nameHash".to_string(), "$createdAtBlockHeight".to_string()], + false + ) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 836c6d46229..01e2abba3d5 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1966,8 +1966,9 @@ mod tests { // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed // contract bytes; the DPNS v3 preorder type the update copies in has its own - // (its per-owner index, `$createdAtBlockHeight` required) - 27003077990 + // (its per-owner index, its non-unique `nameHashAndHeight` index registered at + // 0.01 Dash, `$createdAt` and `$createdAtBlockHeight` required, a ttl) + 28003229100 ); let check_result = platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs index 21205c65d12..c795f661321 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs @@ -29,9 +29,9 @@ use crate::execution::validation::state_transition::batch::data_triggers::{ /// The DPNS `domain` create trigger from protocol version 14, when DPNS v3's /// schema keywords check the rest of a create: the normalized names are /// generated from their counterparts (`generatedFrom`), the preorder is the -/// writer's own from an earlier block and the create deletes it (`refersTo` on -/// `preorderSalt`), and the identity record is the owner (`propertyConstraints` -/// rule `recordsIdentityIsOwner`). +/// creator's own from an earlier block and the create deletes it +/// (`creatorRefersTo`), and the identity record is the owner +/// (`propertyConstraints` rule `recordsIdentityIsOwner`). /// /// What stays here is the domain's place in the name hierarchy: /// diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 3619cdd06e7..9f53abd88bb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -22,7 +22,7 @@ mod creation_tests { use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::document::Document; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - use crate::test::helpers::dpns::dpns_salted_domain_hash; + use crate::test::helpers::dpns::set_dpns_preorder_hashes; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund; @@ -835,25 +835,25 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); let salt_2: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - preorder_document_1.owner_id(), + let preorder_owner = preorder_document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, "quantum", "dash", platform_version, ); - - let salted_domain_hash_2 = dpns_salted_domain_hash( - preorder_document_2.owner_id(), + let preorder_owner = preorder_document_2.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_2, + preorder_owner, &salt_2, "quantum", "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); - document_1.set("preorderSalt", salt_1.into()); document_2.set("preorderSalt", salt_2.into()); @@ -1274,16 +1274,16 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - preorder_document_1.owner_id(), + let preorder_owner = preorder_document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, "quantum", "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - document_1.set("preorderSalt", salt_1.into()); let documents_batch_create_preorder_transition_1 = @@ -1558,16 +1558,16 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - preorder_document_1.owner_id(), + let preorder_owner = preorder_document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, "quantum", "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - document_1.set("preorderSalt", salt_1.into()); let documents_batch_create_preorder_transition_1 = @@ -1852,16 +1852,14 @@ mod creation_tests { let salt_3: [u8; 32] = rng.gen(); - preorder_document_3.set( - "saltedDomainHash", - dpns_salted_domain_hash( - preorder_document_3.owner_id(), - &salt_3, - "quantum", - "dash", - platform_version, - ) - .into(), + let preorder_owner = preorder_document_3.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_3, + preorder_owner, + &salt_3, + "quantum", + "dash", + platform_version, ); document_3.set("preorderSalt", salt_3.into()); @@ -2133,16 +2131,16 @@ mod creation_tests { let salt_1: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - preorder_document_1.owner_id(), + let preorder_owner = preorder_document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, "quantum", "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - document_1.set("preorderSalt", salt_1.into()); let documents_batch_create_preorder_transition_1 = @@ -2554,43 +2552,43 @@ mod creation_tests { let salt_3: [u8; 32] = rng.gen(); let salt_4: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - preorder_document_1.owner_id(), + let preorder_owner = preorder_document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, "quantum", "dash", platform_version, ); - - let salted_domain_hash_2 = dpns_salted_domain_hash( - preorder_document_2.owner_id(), + let preorder_owner = preorder_document_2.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_2, + preorder_owner, &salt_2, "quantum", "dash", platform_version, ); - - let salted_domain_hash_3 = dpns_salted_domain_hash( - preorder_document_3_on_identity_1.owner_id(), + let preorder_owner = preorder_document_3_on_identity_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_3_on_identity_1, + preorder_owner, &salt_3, "cry", "dash", platform_version, ); - - let salted_domain_hash_4 = dpns_salted_domain_hash( - preorder_document_4_on_identity_1.owner_id(), + let preorder_owner = preorder_document_4_on_identity_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_4_on_identity_1, + preorder_owner, &salt_4, &non_contested_normalized_label, "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); - preorder_document_3_on_identity_1.set("saltedDomainHash", salted_domain_hash_3.into()); - preorder_document_4_on_identity_1.set("saltedDomainHash", salted_domain_hash_4.into()); - document_1.set("preorderSalt", salt_1.into()); document_2.set("preorderSalt", salt_2.into()); document_3_on_identity_1.set("preorderSalt", salt_3.into()); @@ -3200,27 +3198,23 @@ mod creation_tests { let contested_salt: [u8; 32] = rng.gen(); let non_contested_salt: [u8; 32] = rng.gen(); - contested_preorder_document.set( - "saltedDomainHash", - dpns_salted_domain_hash( - contested_preorder_document.owner_id(), - &contested_salt, - contested_label, - "dash", - platform_version, - ) - .into(), + let preorder_owner = contested_preorder_document.owner_id(); + set_dpns_preorder_hashes( + &mut contested_preorder_document, + preorder_owner, + &contested_salt, + contested_label, + "dash", + platform_version, ); - non_contested_preorder_document.set( - "saltedDomainHash", - dpns_salted_domain_hash( - non_contested_preorder_document.owner_id(), - &non_contested_salt, - non_contested_normalized_label, - "dash", - platform_version, - ) - .into(), + let preorder_owner = non_contested_preorder_document.owner_id(); + set_dpns_preorder_hashes( + &mut non_contested_preorder_document, + preorder_owner, + &non_contested_salt, + non_contested_normalized_label, + "dash", + platform_version, ); contested_document.set("preorderSalt", contested_salt.into()); non_contested_document.set("preorderSalt", non_contested_salt.into()); 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 47a89d38749..0e3e4a4e098 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 @@ -1029,7 +1029,7 @@ mod dpns_tests { mod dpns_username_transfer_tests { use super::*; - use crate::test::helpers::dpns::dpns_salted_domain_hash; + use crate::test::helpers::dpns::set_dpns_preorder_hashes; use dpp::consensus::basic::BasicError; use dpp::consensus::state::data_trigger::DataTriggerError; use dpp::data_contract::DataContract; @@ -1130,15 +1130,15 @@ mod dpns_username_transfer_tests { let salt: [u8; 32] = rng.gen(); - let salted_domain_hash = dpns_salted_domain_hash( - document.owner_id(), + let preorder_owner = document.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document, + preorder_owner, &salt, &normalized_label, "dash", platform_version, ); - - preorder_document.set("saltedDomainHash", salted_domain_hash.into()); document.set("preorderSalt", salt.into()); let documents_batch_create_preorder_transition = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index ebacfeb614e..c6797f554c0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -1,9 +1,11 @@ //! The DPNS contract v3 (protocol version 14) through the full ABCI pipeline. A //! domain create is checked by schema keywords where the create data trigger //! checked it before: `normalizedLabel` and `normalizedParentDomainName` are -//! generated from their counterparts, `preorderSalt` reveals the writer's own -//! preorder from an earlier block and deletes it, and the -//! `recordsIdentityIsOwner` rule holds a new name's identity record to its owner. +//! generated from their counterparts, `creatorRefersTo` finds the creator's own +//! preorder of the name (by the hash of the creator's id and the normalized name) +//! from an earlier block and deletes it, and the `recordsIdentityIsOwner` rule +//! holds a new name's identity record to its owner. A preorder carries no salt +//! from v3 and lives an hour (`ttl`). //! The trigger keeps only the parent domain checks. Protocol version 13 keeps //! DPNS v2 and the whole trigger, pinned beside each case. @@ -15,7 +17,7 @@ mod dpns_v3_tests { create_dpns_name_contest_give_key_info, perform_votes_multi, }; use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::dpns::dpns_salted_domain_hash; + use crate::test::helpers::dpns::dpns_preorder_properties; use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::basic::BasicError; @@ -259,8 +261,9 @@ mod dpns_v3_tests { (document, result) } - /// A preorder by `who` in block `height` of `label` under `parent`, - /// salted with `salt`. + /// A preorder by `who` in block `height` of `label` under `parent`, with + /// the hashes the DPNS contract of the fixture's protocol version declares + /// (`salt` is read under DPNS v2 only). async fn preorder( &mut self, who: Who, @@ -269,25 +272,18 @@ mod dpns_v3_tests { parent: &str, height: u64, ) -> Document { - let (preorder, result) = self - .create( - who, - "preorder", - &[( - "saltedDomainHash", - dpns_salted_domain_hash( - self.id(who), - &salt, - &convert_to_homograph_safe_chars(label), - parent, - self.platform_version, - ) - .into(), - )], - &[], - height, - ) - .await; + let hashes = dpns_preorder_properties( + self.id(who), + &salt, + &convert_to_homograph_safe_chars(label), + parent, + self.platform_version, + ); + let values: Vec<(&str, Value)> = hashes + .iter() + .map(|(property, value)| (property.as_str(), value.clone())) + .collect(); + let (preorder, result) = self.create(who, "preorder", &values, &[], height).await; assert_matches!( result, StateTransitionExecutionResult::SuccessfulExecution { .. }, @@ -296,9 +292,9 @@ mod dpns_v3_tests { preorder } - /// A domain by `who` in block `height` of `label` under `parent`, - /// revealing `salt`, as a client builds it, with `values` set over it - /// and `absent` removed. + /// A domain by `who` in block `height` of `label` under `parent`, as a + /// client builds it (revealing `salt` under DPNS v2 only), with `values` + /// set over it and `absent` removed. async fn domain( &mut self, who: Who, @@ -320,10 +316,12 @@ mod dpns_v3_tests { "normalizedParentDomainName", Value::Text(convert_to_homograph_safe_chars(parent)), ), - ("preorderSalt", salt.into()), ("records.identity", self.id(who).into()), ("subdomainRules.allowSubdomains", false.into()), ]; + if self.platform_version.system_data_contracts.dpns < 3 { + all_values.push(("preorderSalt", salt.into())); + } all_values.extend(values.iter().cloned()); self.create(who, "domain", &all_values, absent, height) .await @@ -493,12 +491,12 @@ mod dpns_v3_tests { } /// The elements laying out the DPNS `preorder` type: its tree, its primary key - /// tree, its `ownerAndSaltedHash` index tree, and whatever its v2 `saltedHash` - /// index left under `saltedDomainHash`. + /// tree, its `ownerAndNameHash` and `nameHashAndHeight` index trees, and + /// whatever its v2 `saltedHash` index left under `saltedDomainHash`. fn preorder_type_layout( platform: &TempPlatform, platform_version: &PlatformVersion, - ) -> (Element, Element, Element, Option) { + ) -> (Element, Element, Element, Element, Option) { let dpns_id = SystemDataContract::DPNS.id().to_buffer(); let documents_root: &[u8] = Into::<&[u8; 1]>::into(RootTree::DataContractDocuments); let documents_path: [&[u8]; 3] = [documents_root, &dpns_id, &[1]]; @@ -516,6 +514,7 @@ mod dpns_v3_tests { get(&documents_path, b"preorder").expect("expected the preorder type tree"), get(&type_path, &[0]).expect("expected the primary key tree"), get(&type_path, b"$ownerId").expect("expected the owner index tree"), + get(&type_path, b"nameHash").expect("expected the name hash index tree"), get(&type_path, b"saltedDomainHash"), ) } @@ -629,7 +628,7 @@ mod dpns_v3_tests { .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) .await; - // Bob read Alice's salt off her domain create and races it: his reveal hashes his + // Bob read the name off Alice's domain create and races it: his reveal hashes his // own id and looks among his own preorders, so it finds nothing let (_, result) = fixture .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) @@ -644,25 +643,23 @@ mod dpns_v3_tests { /// Preorders are unique per owner and their hash binds the owner, so a copy of /// someone's preorder is harmless: stored first, it does not stop the owner's own - /// preorder, and the copier, who learns the salt from the owner's reveal, cannot - /// reveal the name with it. + /// preorder, and the copier, who learns the name from the owner's reveal, cannot + /// reveal it with the copy. #[tokio::test] async fn should_neither_block_nor_reveal_a_name_with_a_copied_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); let alice = fixture.id(Who::Alice); let salt: [u8; 32] = fixture.rng.gen(); - let alice_hash = - dpns_salted_domain_hash(alice, &salt, "quantum7", "dash", fixture.platform_version); + let alice_hashes = + dpns_preorder_properties(alice, &salt, "quantum7", "dash", fixture.platform_version); + let copied: Vec<(&str, Value)> = alice_hashes + .iter() + .map(|(property, value)| (property.as_str(), value.clone())) + .collect(); // Bob copies Alice's pending commitment, and his copy lands first let (copy, result) = fixture - .create( - Who::Bob, - "preorder", - &[("saltedDomainHash", alice_hash.into())], - &[], - PREORDER_HEIGHT, - ) + .create(Who::Bob, "preorder", &copied, &[], PREORDER_HEIGHT) .await; assert_matches!( result, @@ -672,7 +669,7 @@ mod dpns_v3_tests { .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) .await; - // Bob races Alice's reveal with her salt and his copy + // Bob races Alice's reveal with the name and his copy let (_, result) = fixture .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) .await; @@ -710,6 +707,51 @@ mod dpns_v3_tests { ); } + /// Without a salt, an owner's preorders of one name share their `ownerNameHash`, so + /// an owner holds one pending preorder per name: a second is refused while the first + /// stands, and the first reveals the name + #[tokio::test] + async fn should_hold_one_pending_preorder_per_name_and_owner() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let salt: [u8; 32] = fixture.rng.gen(); + let preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + + let alice = fixture.id(Who::Alice); + let hashes = + dpns_preorder_properties(alice, &salt, "quantum7", "dash", fixture.platform_version); + let values: Vec<(&str, Value)> = hashes + .iter() + .map(|(property, value)| (property.as_str(), value.clone())) + .collect(); + let (_, result) = fixture + .create(Who::Alice, "preorder", &values, &[], REVEAL_HEIGHT) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::DuplicateUniqueIndexError(e)) + if e.duplicating_properties() == &vec!["$ownerId".to_string(), "ownerNameHash".to_string()] + ); + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(fixture.stored_preorder(&preorder).is_none()); + } + #[tokio::test] async fn should_refuse_a_name_without_its_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); @@ -1113,7 +1155,19 @@ mod dpns_v3_tests { ); assert!(fixture.stored_preorder(&preorder).is_some()); - let (preorder, _, result) = fixture.register(Who::Alice, "quantum", &[], &[]).await; + // The refused create left the preorder, which reveals the name again + let salt: [u8; 32] = fixture.rng.gen(); + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum", + "dash", + &[], + &[], + REVEAL_HEIGHT + 1, + ) + .await; assert_matches!( result, StateTransitionExecutionResult::SuccessfulExecution { .. } @@ -1152,10 +1206,10 @@ mod dpns_v3_tests { ) .await; for preorder in [&preorder_1, &preorder_2] { - let salted_domain_hash = preorder + let owner_name_hash = preorder .properties() - .get("saltedDomainHash") - .expect("expected the preorder's salted hash") + .get("ownerNameHash") + .expect("expected the preorder's owner and name hash") .clone(); assert!( query_dpns( @@ -1166,7 +1220,7 @@ mod dpns_v3_tests { "$ownerId", Value::Identifier(preorder.owner_id().to_buffer()) ), - ("saltedDomainHash", salted_domain_hash), + ("ownerNameHash", owner_name_hash), ], platform_version, ) @@ -1237,37 +1291,66 @@ mod dpns_v3_tests { ); } - /// What a client pays for a name: the domain create at protocol version 13 - /// (create trigger, preorder kept) and at 14 (keywords, preorder consumed - /// and its storage refunded to the writer). Printed for the record and - /// pinned. + /// What a client pays for a name: the preorder and the domain create at protocol + /// version 13 (create trigger, preorder kept, stored for good) and at 14 (keywords, a + /// preorder living an hour and priced for that time, consumed by the create without a + /// refund). Printed for the record and pinned. #[tokio::test] - async fn should_charge_a_domain_create_at_protocol_versions_13_and_14() { + async fn should_charge_a_name_at_protocol_versions_13_and_14() { let mut measured = vec![]; for protocol_version in [13, 14] { let mut fixture = DpnsFixture::new(protocol_version); let alice = fixture.id(Who::Alice); - let (_, _, result) = fixture.register(Who::Alice, "quantum7", &[], &[]).await; - let StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } = result - else { - panic!("expected the domain create to succeed, got {result:?}"); - }; - let refund = fee_result - .fee_refunds - .calculate_refunds_amount_for_identity(alice) - .unwrap_or_default(); - println!( - "protocol version {protocol_version}: processing fee {}, storage fee {}, \ - refund {refund}", - fee_result.processing_fee, fee_result.storage_fee + let salt: [u8; 32] = fixture.rng.gen(); + let hashes = dpns_preorder_properties( + alice, + &salt, + "quantum7", + "dash", + fixture.platform_version, ); - measured.push((fee_result.processing_fee, fee_result.storage_fee, refund)); + let values: Vec<(&str, Value)> = hashes + .iter() + .map(|(property, value)| (property.as_str(), value.clone())) + .collect(); + let (_, preorder_result) = fixture + .create(Who::Alice, "preorder", &values, &[], PREORDER_HEIGHT) + .await; + let (_, domain_result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + for (document, result) in [("preorder", preorder_result), ("domain", domain_result)] { + let StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } = result + else { + panic!("expected the {document} create to succeed, got {result:?}"); + }; + let refund = fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(alice) + .unwrap_or_default(); + println!( + "protocol version {protocol_version} {document}: processing fee {}, \ + storage fee {}, refund {refund}", + fee_result.processing_fee, fee_result.storage_fee + ); + measured.push((fee_result.processing_fee, fee_result.storage_fee, refund)); + } } assert_eq!( measured, vec![ + (1_194_500, 31_131_000, 0), (1_876_280, 42_660_000, 0), - (2_525_540, 42_687_000, 15_181_239) + (4_914_640, 11_558_163, 0), + (3_394_500, 42_687_000, 0) ] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs index 3ba79d72233..6209b748f88 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs @@ -7,7 +7,7 @@ use super::*; use crate::platform_types::platform_state::PlatformState; use crate::rpc::core::MockCoreRPCLike; -use crate::test::helpers::dpns::dpns_salted_domain_hash; +use crate::test::helpers::dpns::set_dpns_preorder_hashes; use crate::test::helpers::setup::TempPlatform; use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::document::Document; @@ -523,13 +523,6 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { // DPNS registers a name in two steps: the salted preorder, then the domain. let name = "quantum"; let salt: [u8; 32] = rng.gen(); - let salted_domain_hash = dpns_salted_domain_hash( - identity.id(), - &salt, - &convert_to_homograph_safe_chars(name), - "dash", - platform_version, - ); let preorder_entropy = Bytes32::random_with_rng(&mut rng); let mut preorder_document = preorder .random_document_with_identifier_and_entropy( @@ -541,7 +534,15 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { platform_version, ) .expect("expected a random preorder document"); - preorder_document.set("saltedDomainHash", salted_domain_hash.into()); + let preorder_owner = identity.id(); + set_dpns_preorder_hashes( + &mut preorder_document, + preorder_owner, + &salt, + &convert_to_homograph_safe_chars(name), + "dash", + platform_version, + ); preorder_document .set_id_for_creation(preorder, &preorder_entropy.0, 2, platform_version) .expect("expected to set the preorder id"); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 18f202ce7cd..34102575c16 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -126,7 +126,7 @@ pub(crate) mod test_helpers; #[cfg(test)] pub(in crate::execution) mod tests { use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::dpns::dpns_salted_domain_hash; + use crate::test::helpers::dpns::set_dpns_preorder_hashes; use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; @@ -1352,25 +1352,25 @@ pub(in crate::execution) mod tests { let salt_1: [u8; 32] = rng.gen(); let salt_2: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - document_1.owner_id(), + let preorder_owner = document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, &convert_to_homograph_safe_chars(name), "dash", platform_version, ); - - let salted_domain_hash_2 = dpns_salted_domain_hash( - document_2.owner_id(), + let preorder_owner = document_2.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_2, + preorder_owner, &salt_2, &convert_to_homograph_safe_chars(name), "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - preorder_document_2.set("saltedDomainHash", salted_domain_hash_2.into()); - document_1.set("preorderSalt", salt_1.into()); document_2.set("preorderSalt", salt_2.into()); @@ -1987,16 +1987,16 @@ pub(in crate::execution) mod tests { let salt_1: [u8; 32] = rng.gen(); - let salted_domain_hash_1 = dpns_salted_domain_hash( - document_1.owner_id(), + let preorder_owner = document_1.owner_id(); + set_dpns_preorder_hashes( + &mut preorder_document_1, + preorder_owner, &salt_1, &convert_to_homograph_safe_chars(name), "dash", platform_version, ); - preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); - document_1.set("preorderSalt", salt_1.into()); let documents_batch_create_preorder_transition_1 = diff --git a/packages/rs-drive-abci/src/test/helpers/dpns.rs b/packages/rs-drive-abci/src/test/helpers/dpns.rs index 46e2317e815..1c4a7c93f10 100644 --- a/packages/rs-drive-abci/src/test/helpers/dpns.rs +++ b/packages/rs-drive-abci/src/test/helpers/dpns.rs @@ -1,24 +1,59 @@ +use dpp::document::{Document, DocumentV0Setters}; +use dpp::platform_value::Value; use dpp::prelude::Identifier; use dpp::util::hash::hash_double; use dpp::version::PlatformVersion; -/// The `saltedDomainHash` a DPNS preorder by `owner_id` commits to for the name -/// `normalized_label` under `parent`, as the DPNS system contract of -/// `platform_version` reveals it: from DPNS v3 (protocol version 14) the double -/// SHA-256 of the owner's id, the salt, the normalized label, `"."` and the -/// parent; before it, of the salt and `.`. -pub fn dpns_salted_domain_hash( +/// The hash properties a DPNS preorder by `owner_id` holds for the name +/// `normalized_label` under the normalized `parent`, as the DPNS system contract +/// of `platform_version` declares them. From DPNS v3 (protocol version 14): +/// `ownerNameHash`, the double SHA-256 of the owner's id, the normalized label, +/// `"."` and the parent, and `nameHash`, the same without the owner; `salt` is +/// not read. Before it: `saltedDomainHash`, of `salt` and +/// `.`. +pub fn dpns_preorder_properties( owner_id: Identifier, salt: &[u8; 32], normalized_label: &str, parent: &str, platform_version: &PlatformVersion, -) -> [u8; 32] { - let mut preimage = vec![]; +) -> Vec<(String, Value)> { + let name = format!("{normalized_label}.{parent}"); if platform_version.system_data_contracts.dpns >= 3 { - preimage.extend(owner_id.to_buffer()); + let mut owner_and_name = owner_id.to_buffer().to_vec(); + owner_and_name.extend(name.as_bytes()); + vec![ + ( + "ownerNameHash".to_string(), + Value::Bytes32(hash_double(owner_and_name)), + ), + ( + "nameHash".to_string(), + Value::Bytes32(hash_double(name.into_bytes())), + ), + ] + } else { + let mut preimage = salt.to_vec(); + preimage.extend(name.as_bytes()); + vec![( + "saltedDomainHash".to_string(), + Value::Bytes32(hash_double(preimage)), + )] + } +} + +/// Sets [`dpns_preorder_properties`] on `preorder`. +pub fn set_dpns_preorder_hashes( + preorder: &mut Document, + owner_id: Identifier, + salt: &[u8; 32], + normalized_label: &str, + parent: &str, + platform_version: &PlatformVersion, +) { + for (property, value) in + dpns_preorder_properties(owner_id, salt, normalized_label, parent, platform_version) + { + preorder.set(&property, value); } - preimage.extend(salt); - preimage.extend(format!("{normalized_label}.{parent}").as_bytes()); - hash_double(preimage) } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index d356593b6f2..f2463d70bd7 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -31,7 +31,7 @@ mod tests { use dpp::data_contract::DataContract; use dpp::platform_value::Identifier; use dpp::state_transition::StateTransition; - use drive_abci::test::helpers::dpns::dpns_salted_domain_hash; + use drive_abci::test::helpers::dpns::dpns_preorder_properties; use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use drive::util::object_size_info::DataContractOwnedResolvedInfo; @@ -67,13 +67,15 @@ mod tests { BTreeMap::from([("identity", Value::from(owner_id))]).into(), ), ]); - let salted_domain_hash = - dpns_salted_domain_hash(owner_id, &salt, &normalized_label, "dash", platform_version); + let preorder_values: BTreeMap = + dpns_preorder_properties(owner_id, &salt, &normalized_label, "dash", platform_version) + .into_iter() + .collect(); let preorder_op = DocumentOp { contract: dpns_contract.clone(), action: DocumentAction::DocumentActionInsertSpecific( - BTreeMap::from([("saltedDomainHash".into(), salted_domain_hash.into())]), + preorder_values, Some(owner_id), DocumentFieldFillType::FillIfNotRequired, DocumentFieldFillSize::AnyDocumentFillSize, diff --git a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs index 3ec23d1e961..e10453ed2ab 100644 --- a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs +++ b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs @@ -13,13 +13,20 @@ use grovedb::{BackwardsReferences, Element, Transaction}; /// Whether a contract update lays `document_type` out as a registration does, with nothing of /// it outside its own subtree: an `indexOnly` type has no primary key tree when registered, a -/// contested index keeps vote polls, moderator deletions keep removal records and a ttl keeps -/// expiration entries, none of which a rebuild deletes or lays out. +/// contested index keeps vote polls and moderator deletions keep removal records, none of +/// which a rebuild deletes or lays out. fn rebuilds_like_a_registration(document_type: DocumentTypeRef) -> bool { !document_type.index_only() && document_type.find_contested_index().is_none() && !document_type.documents_can_be_deleted_by_moderators() - && document_type.documents_ttl_seconds().is_none() +} + +/// Whether deleting the subtree of `document_type`, as stored, deletes everything of it: it +/// rebuilds like a registration, and keeps no expiration entries either, which a ttl keeps +/// beside the subtree for every document. A rebuilt type may declare a ttl: it holds no +/// document, so no entry, until one is written. +fn deleting_its_subtree_deletes_everything(document_type: DocumentTypeRef) -> bool { + rebuilds_like_a_registration(document_type) && document_type.documents_ttl_seconds().is_none() } impl Drive { @@ -28,8 +35,11 @@ impl Drive { /// its whole subtree, every document and index entry under it, is deleted, and the update /// then creates the type as it creates a type a contract update adds, which is how a /// contract registration creates it too. The rebuilt types end up holding no documents, - /// laid out exactly as on a chain that registered `contract` directly. A type with trees - /// outside its own subtree ([`rebuilds_like_a_registration`]) is refused. + /// laid out exactly as on a chain that registered `contract` directly. A type already + /// stored as `contract` declares it is left as it is, so running the rebuild again over + /// its own result changes nothing. A type with trees outside its own subtree is refused + /// ([`rebuilds_like_a_registration`], and for the stored type + /// [`deleting_its_subtree_deletes_everything`]). /// /// For a protocol upgrade that changes a system contract's document type in a way a /// contract update cannot follow, such as replacing a unique index; the documents deleted @@ -65,24 +75,29 @@ impl Drive { // creates each of them as a type the update adds let mut original_contract = stored_contract; for document_type_name in rebuilt_document_types { - let stored_type = original_contract - .document_types_mut() - .remove(*document_type_name); - let (Some(stored_type), Ok(new_type)) = ( - stored_type, - contract.document_type_for_name(document_type_name), + let (Some(stored_type), Some(new_type)) = ( + original_contract.document_types().get(*document_type_name), + contract.document_types().get(*document_type_name), ) else { return Err(Error::Drive(DriveError::CorruptedCodeExecution( "a rebuilt document type must be in both the stored and the new contract", ))); }; - if !rebuilds_like_a_registration(stored_type.as_ref()) - || !rebuilds_like_a_registration(new_type) + // A type already stored as `contract` declares it, as when the upgrade runs + // again over its own result, is left as it is: there is nothing to rebuild + if stored_type == new_type { + continue; + } + if !deleting_its_subtree_deletes_everything(stored_type.as_ref()) + || !rebuilds_like_a_registration(new_type.as_ref()) { return Err(Error::Drive(DriveError::CorruptedCodeExecution( "a rebuilt document type may keep no trees outside its own subtree", ))); } + original_contract + .document_types_mut() + .remove(*document_type_name); self.grove .delete( contract_documents_path(&contract_id).as_ref(), @@ -152,7 +167,9 @@ mod tests { 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::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::version::PlatformVersion; @@ -201,7 +218,90 @@ mod tests { .contract .document_type_for_name("preorder") .expect("DPNS has a preorder type"); - assert!(preorder.indexes().contains_key("ownerAndSaltedHash")); + assert!(preorder.indexes().contains_key("ownerAndNameHash")); assert!(!preorder.indexes().contains_key("saltedHash")); + assert_eq!(preorder.documents_ttl_seconds(), Some(3600)); + } + + /// A type already stored as the contract declares it is left as it is, so running the + /// rebuild again over its own result changes nothing, a ttl type included + #[test] + fn should_leave_a_type_already_stored_as_declared() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let dpns_v3 = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS v3 loads"); + drive + .apply_contract( + &dpns_v3, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("DPNS v3 is stored"); + + let transaction = drive.grove.start_transaction(); + drive + .apply_contract_rebuilding_document_types( + &dpns_v3, + &["preorder"], + &BlockInfo::default(), + &transaction, + platform_version, + ) + .expect("a type stored as declared is left alone"); + } + + /// A stored type with a ttl keeps expiration entries beside its subtree, which deleting + /// the subtree would leave behind, so it is not rebuilt into another declaration + #[test] + fn should_refuse_to_rebuild_a_stored_type_with_a_ttl() { + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::data_contract::DataContract; + use dpp::platform_value::Value; + + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let dpns_v3 = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS v3 loads"); + drive + .apply_contract( + &dpns_v3, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("DPNS v3 is stored"); + + // The same contract with another preorder ttl + let mut value = dpns_v3 + .to_value(platform_version) + .expect("DPNS converts to a value"); + value + .set_value_at_full_path("documentSchemas.preorder.ttl", Value::U32(7200)) + .expect("the preorder ttl is set"); + let other_ttl = DataContract::from_value(value, true, platform_version) + .expect("the changed contract parses"); + + let transaction = drive.grove.start_transaction(); + let error = drive + .apply_contract_rebuilding_document_types( + &other_ttl, + &["preorder"], + &BlockInfo::default(), + &transaction, + platform_version, + ) + .expect_err("a stored type with a ttl is not rebuilt"); + assert!( + error + .to_string() + .contains("a rebuilt document type may keep no trees outside its own subtree"), + "got {error}" + ); } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 15df8e03163..0050a7b6e78 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2301,21 +2301,25 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// generates `normalizedLabel` and `normalizedParentDomainName` with /// `sys.stringTransformations.homographSafeASCII` (53; a value sent that /// differs is `DocumentPropertyNotGeneratedError`, 10424). Its -/// `preorderSalt` reveals the writer's own `preorder` from an earlier block -/// and deletes it, the storage refunded to the writer (61: `findBy` -/// `$ownerId` and the sha256d of the writer's id, the salt, the normalized -/// label, `"."` and the parent, `minimumAgeBlocks: 1`, `consume`; refused -/// with 40120 or 40142). Preorders are unique per owner (`ownerAndSaltedHash` -/// over `$ownerId` and `saltedDomainHash`, replacing `saltedHash`), so a -/// copy of a pending preorder neither blocks it nor, its hash binding the -/// owner, lets the copier reveal the name once the salt is public. The rule +/// `creatorRefersTo` finds the creator's own `preorder` of the name from an +/// earlier block and deletes it (61: `findBy` `$ownerId` and `ownerNameHash`, +/// the sha256d of the creator's id, the normalized label, `"."` and the +/// normalized parent, `minimumAgeBlocks: 1`, `consume`; refused with 40120 or +/// 40142). A preorder carries no salt: it holds `ownerNameHash`, unique per +/// owner (`ownerAndNameHash` over `$ownerId` and `ownerNameHash`, replacing +/// `saltedHash`), and `nameHash`, the same hash without the owner, indexed +/// with the creation height (`nameHashAndHeight`), and lives an hour (`ttl` +/// 3600, so it refunds nothing when consumed). A copy of a pending preorder +/// neither blocks it nor, its hash binding the owner, lets the copier reveal +/// the name. The `domain` keeps `preorderSalt` declared, transient and no +/// longer required, so domains written under v1 and v2 decode. The rule /// `recordsIdentityIsOwner` (`$transferredAt` equal to `$createdAt` implies /// `records.identity` equal to `$ownerId`) holds a new name's identity /// record to its owner (10422); a transfer or a purchase in a later block, /// judged with its own time, no longer reads the record, and one in the /// block that created the name is refused. The `domain` sets -/// `canBeDeleted: false` and the `preorder` requires -/// `$createdAtBlockHeight`; the `domain`'s property positions, transient +/// `canBeDeleted: false` and the `preorder` requires `$createdAt` (for its +/// `ttl`) and `$createdAtBlockHeight`; the `domain`'s property positions, transient /// list and stored encoding are v2's, so stored domains decode unchanged. /// `transition_to_version_14` re-stores DPNS through /// `Drive::apply_contract_rebuilding_document_types`: every preorder made diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 56e94ab5f72..90e38da82a7 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -15,8 +15,10 @@ use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::{DocumentType, DocumentTypeRef}; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::{ + DocumentType, DocumentTypeRef, LookupHashKey, LookupKeyParam, +}; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; @@ -25,6 +27,7 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::system_data_contracts::dpns_contract::v3::document_types::preorder::properties as preorder_properties; use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; @@ -60,24 +63,22 @@ fn hash_double(data: Vec) -> [u8; 32] { hash.to_byte_array() } -/// The `saltedDomainHash` a preorder by `owner_id` commits to, for a domain -/// carrying `domain_properties` (its `preorderSalt`, labels and parent), as the -/// DPNS contract the network runs declares it. From DPNS v3 the domain's -/// `preorderSalt` reveals the hash of the writer's id, the salt, the normalized -/// label, `"."` and the parent, and the declaration computes it, as the platform -/// does; before it the salt carries no reference and the hash is of the salt and -/// `.dash`. A reference without a `findBy` function is an error, -/// never the older hash, which such a contract could not reveal. -fn salted_domain_hash( +/// The hash properties a preorder by `owner_id` holds for a domain carrying +/// `domain_properties`, as the DPNS contract the network runs declares them. From DPNS v3 +/// the domain's `creatorRefersTo` finds the creator's own preorder through a `findBy` +/// function over the creator's id, the normalized label, `"."` and the normalized parent: +/// the preorder holds that hash under the property the function fills (`ownerNameHash`), +/// and the same hash without the creator's id under `nameHash`, both computed by the +/// declaration as the platform computes them. Before it the domain declares no such +/// reference, and the preorder holds `saltedDomainHash`, the hash of the domain's +/// `preorderSalt` and `.dash`. A creator reference without a `findBy` +/// function is an error, never the older hash, which such a contract could not reveal. +fn preorder_hash_properties( domain_document_type: DocumentTypeRef, owner_id: Identifier, domain_properties: &BTreeMap, -) -> Result<[u8; 32], Error> { - let Some(reference) = domain_document_type - .flattened_properties() - .get("preorderSalt") - .and_then(|property| property.revealed_reference.as_ref()) - else { +) -> Result, Error> { + let Some(reference) = domain_document_type.creator_reference() else { let (Some(Value::Bytes32(salt)), Some(Value::Text(normalized_label))) = ( domain_properties.get("preorderSalt"), domain_properties.get("normalizedLabel"), @@ -88,21 +89,43 @@ fn salted_domain_hash( }; let mut salted_domain_buffer = salt.to_vec(); salted_domain_buffer.extend(format!("{normalized_label}.dash").as_bytes()); - return Ok(hash_double(salted_domain_buffer)); + return Ok(BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(hash_double(salted_domain_buffer)), + )])); }; - let key = reference.lookup_hash_key().ok_or_else(|| { - Error::Generic( - "the DPNS domain's preorderSalt reference declares no findBy function".to_string(), - ) - })?; - key.key_value(domain_document_type, None, owner_id, domain_properties) - .map(|(hash, _)| hash) - .map_err(|error| { - Error::Generic(format!( - "cannot compute the DPNS preorder hash: {} {}", - error.param, error.reason - )) - }) + let (owner_name_hash_property, key) = reference + .as_any_document_reference() + .and_then(|declaration| declaration.lookup) + .and_then(|lookup| lookup.hash_key()) + .ok_or_else(|| { + Error::Generic( + "the DPNS domain's creatorRefersTo declares no findBy function".to_string(), + ) + })?; + let hash = |key: &LookupHashKey| { + key.key_value(domain_document_type, None, owner_id, domain_properties) + .map(|(hash, _)| Value::Bytes32(hash)) + .map_err(|error| { + Error::Generic(format!( + "cannot compute the DPNS preorder hash: {} {}", + error.param, error.reason + )) + }) + }; + let name_key = LookupHashKey { + function: key.function, + params: key + .params + .iter() + .filter(|param| !matches!(param, LookupKeyParam::Writer)) + .cloned() + .collect(), + }; + Ok(BTreeMap::from([ + (owner_name_hash_property.to_string(), hash(key)?), + (preorder_properties::NAME_HASH.to_string(), hash(&name_key)?), + ])) } /// Callback type for preorder document @@ -141,6 +164,8 @@ pub struct RegisterDpnsNameResult { /// The documents a DPNS registration submits, built against one DPNS contract. struct DpnsRegistrationDocuments { + /// The DPNS contract the network stores now, which the documents were built against. + dpns_contract: DataContract, preorder_document_type: DocumentType, preorder_document: Document, domain_document_type: DocumentType, @@ -254,20 +279,17 @@ impl Sdk { .document_type_for_name("domain") .map_err(|_| Error::Generic("DPNS domain document type not found".to_string()))?; - // Generate the preorder salt - let mut rng = StdRng::from_entropy(); - let salt: [u8; 32] = rng.gen(); - // The id of a new document commits to the identity contract nonce of // its create transition, so it only exists once `put_to_platform` has // fetched that nonce. The documents are built with a placeholder id; // the confirmed documents carry the real one. Nothing here needs the - // ids up front: the domain is tied to its preorder by the salt. + // ids up front: the domain finds its preorder by the hash of its creator + // and its name (by the salt before DPNS v3). let preorder_id = Identifier::default(); let domain_id = Identifier::default(); let normalized_label = convert_to_homograph_safe_chars(label); - let domain_properties = BTreeMap::from([ + let mut domain_properties = BTreeMap::from([ ( "parentDomainName".to_string(), Value::Text("dash".to_string()), @@ -281,7 +303,6 @@ impl Sdk { "normalizedLabel".to_string(), Value::Text(normalized_label.clone()), ), - ("preorderSalt".to_string(), Value::Bytes32(salt)), ( "records".to_string(), Value::Map(vec![( @@ -298,19 +319,22 @@ impl Sdk { ), ]); - // The salted domain hash the preorder commits to - let salted_domain_hash = - salted_domain_hash(domain_document_type, identity_id, &domain_properties)?; + // Before DPNS v3 the domain reveals the salt its preorder hashed + if domain_document_type.creator_reference().is_none() { + let salt: [u8; 32] = StdRng::from_entropy().gen(); + domain_properties.insert("preorderSalt".to_string(), Value::Bytes32(salt)); + } + + // The hashes the preorder holds + let preorder_properties = + preorder_hash_properties(domain_document_type, identity_id, &domain_properties)?; // Create preorder document let preorder_document = Document::V0(DocumentV0 { contract_version: None, id: preorder_id, owner_id: identity_id, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - Value::Bytes32(salted_domain_hash), - )]), + properties: preorder_properties, revision: None, created_at: None, updated_at: None, @@ -349,6 +373,7 @@ impl Sdk { Ok(DpnsRegistrationDocuments { preorder_document_type: preorder_document_type.to_owned_document_type(), + dpns_contract: dpns_contract.clone(), preorder_document, domain_document_type: domain_document_type.to_owned_document_type(), domain_document, @@ -381,6 +406,7 @@ impl Sdk { ) -> Result { let identity_id = input.identity.id().to_owned(); let DpnsRegistrationDocuments { + dpns_contract, preorder_document_type, preorder_document, domain_document_type, @@ -390,23 +416,35 @@ impl Sdk { .dpns_registration_documents(&input.label, identity_id) .await?; - // Submit preorder document first - debug!(%identity_id, stage = "preorder", "DPNS registration: submitting document"); - let platform_preorder_document = preorder_document - .put_to_platform_and_wait_for_response( - self, - preorder_document_type, - None, // entropy: generated together with the id once the nonce is known - input.identity_public_key.clone(), - None, // token payment info - &input.signer, - None, // settings - ) - .await - .inspect_err(|error| { - warn!(%identity_id, stage = "preorder", %error, "DPNS registration: document failed"); - })?; - debug!(%identity_id, document_id = %platform_preorder_document.id(), stage = "preorder", "DPNS registration: document confirmed"); + // From DPNS v3 an identity holds one pending preorder of a name: one an earlier + // registration left behind, whose domain create failed, is revealed again rather + // than preordered anew, which the platform would refuse as a duplicate + let pending_preorder = self + .pending_dpns_preorder(dpns_contract, identity_id, &preorder_document) + .await?; + let platform_preorder_document = if let Some(pending_preorder) = pending_preorder { + debug!(%identity_id, document_id = %pending_preorder.id(), stage = "preorder", "DPNS registration: revealing a pending preorder"); + pending_preorder + } else { + // Submit preorder document first + debug!(%identity_id, stage = "preorder", "DPNS registration: submitting document"); + let platform_preorder_document = preorder_document + .put_to_platform_and_wait_for_response( + self, + preorder_document_type, + None, // entropy: generated together with the id once the nonce is known + input.identity_public_key.clone(), + None, // token payment info + &input.signer, + None, // settings + ) + .await + .inspect_err(|error| { + warn!(%identity_id, stage = "preorder", %error, "DPNS registration: document failed"); + })?; + debug!(%identity_id, document_id = %platform_preorder_document.id(), stage = "preorder", "DPNS registration: document confirmed"); + platform_preorder_document + }; // Call the preorder callback if provided if let Some(callback) = input.preorder_callback { @@ -445,6 +483,60 @@ impl Sdk { }) } + /// The preorder `owner_id` already holds of the name `preorder_document` commits to, + /// found by its owner and its `ownerNameHash` (the `ownerAndNameHash` index), when + /// `dpns_contract` is DPNS v3 or later; `None` when there is none, and before v3, + /// whose preorders hash a fresh salt and so never repeat. + async fn pending_dpns_preorder( + &self, + dpns_contract: DataContract, + owner_id: Identifier, + preorder_document: &Document, + ) -> Result, Error> { + use crate::platform::documents::document_query::DocumentQuery; + use drive::query::{WhereClause, WhereOperator}; + + let Some(owner_name_hash) = preorder_document + .properties() + .get(preorder_properties::OWNER_NAME_HASH) + .cloned() + else { + return Ok(None); + }; + let query = DocumentQuery { + select: drive::query::SelectProjection::documents(), + data_contract: Arc::new(dpns_contract), + document_type_name: "preorder".to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner_id.to_buffer()), + }, + WhereClause { + field: preorder_properties::OWNER_NAME_HASH.to_string(), + operator: WhereOperator::Equal, + value: owner_name_hash, + }, + ], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + }; + // A proven absence comes back as entries holding `None` + Ok(Document::fetch_many(self, query) + .await? + .into_values() + .flatten() + .next()) + } + /// Check if a DPNS name is available /// /// # Arguments @@ -692,7 +784,8 @@ mod tests { const SALT: [u8; 32] = [9u8; 32]; - /// The properties of a domain `Bob.dash` the preorder hash may read. + /// The properties of a domain `Bob.dash` the preorder hashes may read (the salt, read + /// under DPNS v2 only). fn bob_domain_properties() -> BTreeMap { BTreeMap::from([ ("label".to_string(), Value::Text("Bob".to_string())), @@ -712,19 +805,19 @@ mod tests { ]) } - /// The preorder hash the SDK computes for `owner_id` and `properties` against the DPNS - /// contract of `platform_version`. - fn preorder_hash( + /// The preorder hash properties the SDK computes for `owner_id` and `properties` + /// against the DPNS contract of `platform_version`. + fn preorder_hashes( platform_version: &PlatformVersion, owner_id: Identifier, properties: &BTreeMap, - ) -> Result<[u8; 32], Error> { + ) -> Result, Error> { let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) .expect("the DPNS contract loads"); let domain = contract .document_type_for_name("domain") .expect("the DPNS contract has a domain type"); - salted_domain_hash(domain, owner_id, properties) + preorder_hash_properties(domain, owner_id, properties) } fn sha256d(parts: &[&[u8]]) -> [u8; 32] { @@ -735,25 +828,44 @@ mod tests { #[test] fn should_hash_the_salt_and_name_whoever_the_owner_for_dpns_v2() { let platform_version = PlatformVersion::get(13).expect("protocol version 13"); - let expected = sha256d(&[&SALT, b"b0b.dash"]); + let expected = BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(sha256d(&[&SALT, b"b0b.dash"])), + )]); for owner_id in [Identifier::new([1u8; 32]), Identifier::new([2u8; 32])] { assert_eq!( - preorder_hash(platform_version, owner_id, &bob_domain_properties()) + preorder_hashes(platform_version, owner_id, &bob_domain_properties()) .expect("DPNS v2 hashes the salt and the name"), expected ); } } + /// DPNS v3 hashes the owner and the name its creator reference declares, and the same + /// name without the owner, which is alike for every owner; the salt is not read #[test] - fn should_hash_the_owner_salt_and_name_the_dpns_v3_contract_declares() { + fn should_hash_the_owner_and_name_the_dpns_v3_contract_declares() { let platform_version = PlatformVersion::latest(); + let mut without_salt = bob_domain_properties(); + without_salt.remove("preorderSalt"); for owner_id in [Identifier::new([1u8; 32]), Identifier::new([2u8; 32])] { - assert_eq!( - preorder_hash(platform_version, owner_id, &bob_domain_properties()) - .expect("DPNS v3 hashes the declared params"), - sha256d(&[owner_id.as_slice(), &SALT, b"b0b", b".", b"dash"]), - ); + let expected = BTreeMap::from([ + ( + "ownerNameHash".to_string(), + Value::Bytes32(sha256d(&[owner_id.as_slice(), b"b0b", b".", b"dash"])), + ), + ( + "nameHash".to_string(), + Value::Bytes32(sha256d(&[b"b0b", b".", b"dash"])), + ), + ]); + for properties in [&bob_domain_properties(), &without_salt] { + assert_eq!( + preorder_hashes(platform_version, owner_id, properties) + .expect("DPNS v3 hashes the declared params"), + expected + ); + } } } @@ -761,7 +873,7 @@ mod tests { fn should_refuse_a_dpns_v3_domain_missing_a_hashed_property() { let mut properties = bob_domain_properties(); properties.remove("normalizedLabel"); - assert!(preorder_hash( + assert!(preorder_hashes( PlatformVersion::latest(), Identifier::new([1u8; 32]), &properties @@ -832,23 +944,27 @@ mod tests { .expect("the registration documents are built"); assert_eq!(sdk.protocol_version_number(), LATEST_VERSION); - let Some(Value::Bytes32(salt)) = documents.domain_document.properties().get("preorderSalt") - else { - panic!("the domain carries a 32-byte preorderSalt"); - }; - assert_eq!(documents.preorder_document.owner_id(), owner_id); - assert_eq!( + assert!( documents - .preorder_document + .domain_document .properties() - .get("saltedDomainHash"), - Some(&Value::Bytes32(sha256d(&[ - owner_id.as_slice(), - salt, - b"b0b", - b".", - b"dash" - ]))), + .get("preorderSalt") + .is_none(), + "a DPNS v3 domain reveals no salt" + ); + assert_eq!(documents.preorder_document.owner_id(), owner_id); + assert_eq!( + documents.preorder_document.properties(), + &BTreeMap::from([ + ( + "ownerNameHash".to_string(), + Value::Bytes32(sha256d(&[owner_id.as_slice(), b"b0b", b".", b"dash"])), + ), + ( + "nameHash".to_string(), + Value::Bytes32(sha256d(&[b"b0b", b".", b"dash"])), + ), + ]), ); } @@ -882,9 +998,9 @@ mod tests { dpns_contract .document_type_for_name("domain") .expect("the DPNS contract has a domain type") - .flattened_properties() - .get("preorderSalt") - .is_some_and(|property| property.revealed_reference.is_some()) + .creator_reference() + .and_then(|reference| reference.lookup_hash_key()) + .is_some() } #[tokio::test] diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index dd6287edb68..924dc934f05 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -127,15 +127,16 @@ describe('Document State Transitions', function describeDocumentStateTransitions // Document operations require at least HIGH security level (key index 2) for signing const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); - // Create a DPNS preorder document for testing - // Using hash of salted domain label - const saltedDomainHash = `${Date.now()}`.padStart(64, '0'); + // Create a DPNS preorder document for testing: from DPNS v3 it holds the hash + // of its owner and a name, and the hash of the name alone (any 32 bytes here) + const hashBytes = (seed: string) => Uint8Array.from( + `${seed}${Date.now()}`.padStart(64, '0').match(/.{2}/g).map((byte) => parseInt(byte, 16)), + ); const document = new sdk.Document({ properties: { - saltedDomainHash: Uint8Array.from( - saltedDomainHash.match(/.{2}/g).map((byte) => parseInt(byte, 16)), - ), + ownerNameHash: hashBytes('1'), + nameHash: hashBytes('2'), }, documentTypeName: 'preorder', revision: 1, From aaa0bb738066aa40bb1cd1f7430933776fccfaf1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 15:56:41 +0300 Subject: [PATCH 20/62] fix(sdk): dpns registration decodes under the contract it built against, and reuses a pending preorder - rs-sdk registers the DPNS contract it fetched and built the documents against with its context provider, so the preorder and domain confirmations decode under it. A known-contracts cache warmed with DPNS v2 would otherwise decode the v3 preorder's proof under v2's single property and refuse it, after the preorder was paid for. - The v3 preorder property names are spelled in the module rather than imported from the bundled DPNS contract, which the SDK builds only with that contract's feature (`cargo check -p dash-sdk --no-default-features` builds). - The pending preorder lookup is a shared query builder (`pending_preorder_query`). - js-dash-sdk looks for a pending v3 preorder by its owner and `ownerNameHash` before preordering, reveals it instead, and propagates a failed query; v1 and v2 keep the fresh salt. Tests: rs-sdk (the exact predicates, a pending preorder returned, none in a proven absence, a failed query propagated, no query for a salted preorder, the registration leaving a writable v2-seeded provider holding v3); js-dash-sdk (the query, the reuse, a failed query, no query under v2). Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/names/register.spec.ts | 77 +++++ .../Client/Platform/methods/names/register.ts | 41 ++- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 282 +++++++++++++++--- 3 files changed, 348 insertions(+), 52 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index ba4a166f2dc..a8814929a37 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -78,6 +78,8 @@ describe('Platform', () => { documents: { create: this.sinon.stub(), broadcast: this.sinon.stub(), + // No pending preorder of the name + get: this.sinon.stub().resolves([]), }, // A DPNS contract declaring no preorder hash: the hash of the salt and the name contracts: { @@ -273,6 +275,81 @@ describe('Platform', () => { expect(platformMock.documents.broadcast.called).to.equal(false); }); + it('should look for a pending preorder by its owner and owner name hash under DPNS v3', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves({ + getDocumentSchema: dpnsV3DomainSchema, + }); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + expect(platformMock.documents.get.getCall(0).args).to.deep.equal([ + 'dpns.preorder', + { + where: [ + ['$ownerId', '==', identityId], + ['ownerNameHash', '==', dpnsV3Preorder(identityId).ownerNameHash], + ], + limit: 1, + }, + ]); + }); + + it('should reveal a pending preorder instead of preordering the name again', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves({ + getDocumentSchema: dpnsV3DomainSchema, + }); + platformMock.documents.get.resolves([{ pending: 'preorder' }]); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + // Only the domain is created and broadcast + expect(platformMock.documents.create.callCount).to.equal(1); + expect(platformMock.documents.create.getCall(0).args[0]).to.equal('dpns.domain'); + expect(platformMock.documents.broadcast.callCount).to.equal(1); + }); + + it('should fail when the pending preorder query fails', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves({ + getDocumentSchema: dpnsV3DomainSchema, + }); + platformMock.documents.get.rejects(new Error('query failed')); + + let error; + try { + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + } catch (e: any) { + error = e; + } + + expect(error.message).to.equal('query failed'); + expect(platformMock.documents.create.called).to.equal(false); + expect(platformMock.documents.broadcast.called).to.equal(false); + }); + + it('should not look for a pending preorder under DPNS v2', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + expect(platformMock.documents.get.called).to.equal(false); + expect(platformMock.documents.create.getCall(0).args[0]).to.equal('dpns.preorder'); + }); + it('should fail if DPNS app is not set up', async () => { platformMock.client.getApps = () => new ClientApps({}); diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 777bec3d177..ff897b68fdb 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -112,19 +112,34 @@ export async function register( ])), }; - // 1. Create preorder document - const preorderDocument = await this.documents.create( - 'dpns.preorder', - identity, - preorderProperties, - ); - - await this.documents.broadcast( - { - create: [preorderDocument], - }, - identity, - ); + // 1. Create preorder document. From DPNS v3 an identity holds one pending + // preorder of a name: one an earlier registration left behind, whose domain + // create failed, is revealed again rather than preordered anew, which the + // platform would refuse as a duplicate. A failed query is an error. + const [pendingPreorder] = Array.isArray(hashParams) + ? await this.documents.get('dpns.preorder', { + where: [ + ['$ownerId', '==', identity.getId()], + [hashProperty as string, '==', preorderProperties[hashProperty as string]], + ], + limit: 1, + }) + : []; + + if (!pendingPreorder) { + const preorderDocument = await this.documents.create( + 'dpns.preorder', + identity, + preorderProperties, + ); + + await this.documents.broadcast( + { + create: [preorderDocument], + }, + identity, + ); + } // 3. Create domain document const domainDocument = await this.documents.create( diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 90e38da82a7..45646ea1d4a 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -7,11 +7,13 @@ pub use dash_platform_queries::dpns_usernames::{ }; pub use queries::DpnsUsername; +use crate::platform::documents::document_query::DocumentQuery; use crate::platform::transition::put_document::PutDocument; use crate::platform::transition::put_settings::PutSettings; use crate::platform::{DataContract, Document, Fetch, FetchMany}; use crate::{Error, Sdk}; use dapi_grpc::platform::v0::ResponseMetadata; +use dash_context_provider::ContextProvider; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -27,7 +29,7 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; -use dpp::system_data_contracts::dpns_contract::v3::document_types::preorder::properties as preorder_properties; +use drive::query::{SelectProjection, WhereClause, WhereOperator}; use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; @@ -55,6 +57,14 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } +/// The property a DPNS v3 preorder holds the hash of its owner and the name under, as the +/// contract names it. Spelled here rather than read from the bundled DPNS contract, which the +/// SDK builds only with that contract's feature. +const OWNER_NAME_HASH: &str = "ownerNameHash"; + +/// The property a DPNS v3 preorder holds the hash of the name alone under. +const NAME_HASH: &str = "nameHash"; + /// Hash a buffer twice using SHA256 (double SHA256) fn hash_double(data: Vec) -> [u8; 32] { use dpp::dashcore::hashes::{sha256d, Hash}; @@ -124,10 +134,45 @@ fn preorder_hash_properties( }; Ok(BTreeMap::from([ (owner_name_hash_property.to_string(), hash(key)?), - (preorder_properties::NAME_HASH.to_string(), hash(&name_key)?), + (NAME_HASH.to_string(), hash(&name_key)?), ])) } +/// The query for the preorder `owner_id` holds with `owner_name_hash`, through the DPNS v3 +/// `ownerAndNameHash` index: at most one exists. +fn pending_preorder_query( + dpns_contract: Arc, + owner_id: Identifier, + owner_name_hash: Value, +) -> DocumentQuery { + DocumentQuery { + select: SelectProjection::documents(), + data_contract: dpns_contract, + document_type_name: "preorder".to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner_id.to_buffer()), + }, + WhereClause { + field: OWNER_NAME_HASH.to_string(), + operator: WhereOperator::Equal, + value: owner_name_hash, + }, + ], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + } +} + /// Callback type for preorder document pub type PreorderCallback = Box; @@ -270,6 +315,14 @@ impl Sdk { ) -> Result { let dpns_contract = self.fetch_current_dpns_contract().await?; + // The confirmations of both documents are proofs the SDK decodes under the DPNS + // contract its context provider resolves, which may be an older one cached for the + // id (a known-contracts cache warmed before the upgrade): give it the contract the + // documents are built against, so they decode under it + if let Some(provider) = self.context_provider() { + provider.register_data_contract(Arc::new(dpns_contract.clone())); + } + // Get document types let preorder_document_type = dpns_contract .document_type_for_name("preorder") @@ -486,49 +539,18 @@ impl Sdk { /// The preorder `owner_id` already holds of the name `preorder_document` commits to, /// found by its owner and its `ownerNameHash` (the `ownerAndNameHash` index), when /// `dpns_contract` is DPNS v3 or later; `None` when there is none, and before v3, - /// whose preorders hash a fresh salt and so never repeat. + /// whose preorders hash a fresh salt and so never repeat. A failed query is an error. async fn pending_dpns_preorder( &self, dpns_contract: DataContract, owner_id: Identifier, preorder_document: &Document, ) -> Result, Error> { - use crate::platform::documents::document_query::DocumentQuery; - use drive::query::{WhereClause, WhereOperator}; - - let Some(owner_name_hash) = preorder_document - .properties() - .get(preorder_properties::OWNER_NAME_HASH) - .cloned() + let Some(owner_name_hash) = preorder_document.properties().get(OWNER_NAME_HASH).cloned() else { return Ok(None); }; - let query = DocumentQuery { - select: drive::query::SelectProjection::documents(), - data_contract: Arc::new(dpns_contract), - document_type_name: "preorder".to_string(), - where_clauses: vec![ - WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(owner_id.to_buffer()), - }, - WhereClause { - field: preorder_properties::OWNER_NAME_HASH.to_string(), - operator: WhereOperator::Equal, - value: owner_name_hash, - }, - ], - time_range_clauses: vec![], - integer_range_clauses: vec![], - sub_queries: vec![], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - offset: None, - start: None, - }; + let query = pending_preorder_query(Arc::new(dpns_contract), owner_id, owner_name_hash); // A proven absence comes back as entries holding `None` Ok(Document::fetch_many(self, query) .await? @@ -695,6 +717,7 @@ fn identity_from_domain_records( #[cfg(test)] mod tests { use super::*; + use crate::query_types::Documents; use crate::sdk::test::expect_epoch_refresh; use crate::SdkBuilder; use dash_context_provider::{ContextProvider, ContextProviderError}; @@ -882,8 +905,13 @@ mod tests { } /// A context provider seeded before the upgrade to DPNS v3: it holds DPNS v2 whatever - /// protocol version it is asked for, as a provider's known contracts do. - struct DpnsV2ContextProvider; + /// protocol version it is asked for, as a provider's known contracts do, until a contract + /// is registered with it, which then takes its place (a writable known-contracts cache). + /// Its clones share what was registered. + #[derive(Clone, Default)] + struct DpnsV2ContextProvider { + registered: Arc>>>, + } impl ContextProvider for DpnsV2ContextProvider { fn get_data_contract( @@ -891,6 +919,9 @@ mod tests { _id: &Identifier, _platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { + if let Some(contract) = self.registered.lock().expect("the lock").clone() { + return Ok(Some(contract)); + } let platform_version = PlatformVersion::get(13).expect("protocol version 13"); load_system_data_contract(SystemDataContract::DPNS, platform_version) .map(|contract| Some(Arc::new(contract))) @@ -918,15 +949,20 @@ mod tests { fn get_platform_activation_height(&self) -> Result { Ok(1) } + + fn register_data_contract(&self, contract: Arc) { + *self.registered.lock().expect("the lock") = Some(contract); + } } #[tokio::test] async fn should_build_the_registration_against_the_dpns_contract_the_network_stores() { // An SDK seeded at protocol version 13, whose context provider holds DPNS v2, on a // network that runs the latest version and stores DPNS v3 + let provider = DpnsV2ContextProvider::default(); let mut sdk = SdkBuilder::new_mock() .with_initial_version(PlatformVersion::get(13).expect("protocol version 13")) - .with_context_provider(DpnsV2ContextProvider) + .with_context_provider(provider.clone()) .build() .expect("the mock SDK builds"); expect_epoch_refresh(&mut sdk).await; @@ -966,6 +1002,174 @@ mod tests { ), ]), ); + // The documents' confirmations decode under the contract the provider resolves, + // which now is the one they were built against + let resolved = provider + .get_data_contract(&stored.id(), PlatformVersion::latest()) + .expect("the provider answers") + .expect("the provider holds DPNS"); + assert!(declares_the_preorder_hash(&resolved)); + } + + /// A DPNS v3 preorder of `owner` for `Bob.dash`, with the hashes the contract declares. + fn bob_v3_preorder(owner: Identifier) -> Document { + let properties = + preorder_hashes(PlatformVersion::latest(), owner, &bob_domain_properties()) + .expect("DPNS v3 hashes the declared params"); + Document::V0(DocumentV0 { + contract_version: None, + id: Identifier::new([7u8; 32]), + owner_id: owner, + properties, + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: Some(10), + 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, + creator_id: None, + moderated_at: None, + moderated_by: None, + }) + } + + fn dpns_v3() -> DataContract { + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("the DPNS contract loads") + } + + /// A mock SDK at the latest version; a request without an expectation fails. + fn mock_sdk() -> Sdk { + SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .with_context_provider(DpnsV2ContextProvider::default()) + .build() + .expect("the mock SDK builds") + } + + /// The pending preorder is looked up through `ownerAndNameHash`: the owner and the + /// preorder's own `ownerNameHash`, at most one + #[test] + fn should_query_a_pending_preorder_by_its_owner_and_owner_name_hash() { + let owner = Identifier::new([1u8; 32]); + let hash = Value::Bytes32([4u8; 32]); + let query = pending_preorder_query(Arc::new(dpns_v3()), owner, hash.clone()); + assert_eq!(query.document_type_name, "preorder"); + assert_eq!( + query.where_clauses, + vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner.to_buffer()), + }, + WhereClause { + field: "ownerNameHash".to_string(), + operator: WhereOperator::Equal, + value: hash, + }, + ] + ); + assert_eq!(query.limit, 1); + } + + #[tokio::test] + async fn should_return_the_pending_preorder_the_network_holds() { + let owner = Identifier::new([1u8; 32]); + let pending = bob_v3_preorder(owner); + let mut sdk = mock_sdk(); + let query = pending_preorder_query( + Arc::new(dpns_v3()), + owner, + pending.properties()["ownerNameHash"].clone(), + ); + sdk.mock() + .expect_fetch_many::( + query, + Some( + [(pending.id(), Some(pending.clone()))] + .into_iter() + .collect(), + ), + ) + .await + .expect("the expectation is set"); + + let found = sdk + .pending_dpns_preorder(dpns_v3(), owner, &bob_v3_preorder(owner)) + .await + .expect("the query answers") + .expect("the pending preorder is found"); + // The response is decoded as a proof is, so compare what identifies the preorder + assert_eq!(found.id(), pending.id()); + assert_eq!(found.owner_id(), owner); + assert_eq!( + found + .properties() + .get("ownerNameHash") + .and_then(|hash| hash.as_bytes_slice().ok()), + pending.properties()["ownerNameHash"].as_bytes_slice().ok() + ); + } + + #[tokio::test] + async fn should_find_no_pending_preorder_in_a_proven_absence() { + let owner = Identifier::new([1u8; 32]); + let preorder = bob_v3_preorder(owner); + let mut sdk = mock_sdk(); + let query = pending_preorder_query( + Arc::new(dpns_v3()), + owner, + preorder.properties()["ownerNameHash"].clone(), + ); + sdk.mock() + .expect_fetch_many::( + query, + Some([(preorder.id(), None)].into_iter().collect()), + ) + .await + .expect("the expectation is set"); + + assert_eq!( + sdk.pending_dpns_preorder(dpns_v3(), owner, &preorder) + .await + .expect("the query answers"), + None + ); + } + + /// A query that fails is an error, never taken for no pending preorder + #[tokio::test] + async fn should_propagate_a_failed_pending_preorder_query() { + let owner = Identifier::new([1u8; 32]); + // No expectation: the mock fails the request + let sdk = mock_sdk(); + assert!(sdk + .pending_dpns_preorder(dpns_v3(), owner, &bob_v3_preorder(owner)) + .await + .is_err()); + } + + /// Before DPNS v3 a preorder hashes a fresh salt, so none repeats: nothing is queried + #[tokio::test] + async fn should_not_look_for_a_pending_salted_preorder() { + let owner = Identifier::new([1u8; 32]); + let mut salted = bob_v3_preorder(owner); + let Document::V0(ref mut v0) = salted; + v0.properties = + BTreeMap::from([("saltedDomainHash".to_string(), Value::Bytes32([5u8; 32]))]); + // No expectation: a query would fail the call + let sdk = mock_sdk(); + assert_eq!( + sdk.pending_dpns_preorder(dpns_v3(), owner, &salted) + .await + .expect("nothing is queried"), + None + ); } /// The DPNS contract of `platform_version`, as a proof decodes it under `parsed_under`: From b1ae90f289b9b62ddc32d4ff722aac753675b909 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 16:39:16 +0300 Subject: [PATCH 21/62] fix(wasm-sdk): the trusted context forwards a registered contract to its cache `WasmTrustedContext` forwarded `get_data_contract` to its inner `TrustedHttpContextProvider` but not `register_data_contract`, so the trait's no-op ran. A DPNS registration that registers the v3 contract it built against left a cache seeded with DPNS v2 (by `addKnownContract`, a contract fetch or a restored store) in place, and the preorder's confirmation decoded under v2 and failed after the preorder was paid for. The call is now forwarded. Test: a context seeded with DPNS v2 resolves DPNS to v3 once v3 is registered. Co-Authored-By: Claude Opus 5.5 --- packages/wasm-sdk/src/context_provider.rs | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index e74053be67f..071a5a9a197 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -116,6 +116,13 @@ impl ContextProvider for WasmTrustedContext { self.inner.get_data_contract(id, platform_version) } + /// Forwarded to the inner known-contracts cache, so a contract the SDK registers (the + /// DPNS contract a name registration was built against, whose confirmations decode under + /// it) replaces the one the cache held. + fn register_data_contract(&self, contract: Arc) { + self.inner.register_data_contract(contract) + } + fn get_token_configuration( &self, token_id: &Identifier, @@ -398,6 +405,8 @@ impl WasmTrustedContext { mod tests { use super::*; use dash_sdk::dpp::bls_signatures::{Bls12381G2Impl, SecretKey}; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::thread; @@ -561,6 +570,31 @@ mod tests { stream.flush().expect("flush quorum response"); } + /// A contract the SDK registers replaces the one the known-contracts cache held: a DPNS + /// name registration built against DPNS v3 decodes its confirmations under v3, even when + /// the cache was seeded with v2 + #[test] + fn should_forward_a_registered_contract_to_the_known_contracts_cache() { + let context = WasmTrustedContext::for_testing(vec![]); + let dpns_v2 = load_system_data_contract( + SystemDataContract::DPNS, + PlatformVersion::get(13).expect("protocol version 13"), + ) + .expect("DPNS v2 loads"); + let dpns_v3 = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS v3 loads"); + context.add_known_contract(dpns_v2); + + ContextProvider::register_data_contract(&context, Arc::new(dpns_v3.clone())); + + let resolved = context + .get_data_contract(&dpns_v3.id(), PlatformVersion::latest()) + .expect("the context answers") + .expect("the context holds DPNS"); + assert_eq!(*resolved, dpns_v3); + } + #[tokio::test] async fn prefetch_skips_masternode_discovery_when_disabled() { let (base_url, server) = spawn_endpoint( From 1cfe1505604022a83fd3637ee7e624bfd96de1e2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 13 Oct 2026 13:54:52 +0300 Subject: [PATCH 22/62] feat(platform)!: move DPNS v3 to protocol version 15 and choose the first valid preorder DPNS v3 now activates at protocol version 15: - SYSTEM_DATA_CONTRACT_VERSIONS_V4 selects dpns 3 and DRIVE_ABCI_VALIDATION_VERSIONS_V11 selects create_domain_data_trigger 2, both only in PLATFORM_V15; the v14 tables are v5.0-dev's again. - The preorder rebuild runs in a new transition_to_version_15. - Data trigger bindings v2 are no longer edited: their domain Replace and Delete rejects are never reached once the schema refuses both. The domain's creatorRefersTo adopts chooseFirst (#5371): findBy the creator and nameHash, where $ownerId for consume, and a reveal is refused (40149) while another identity holds an older valid preorder of the name, one holding the hash of its own owner and the name at ownerNameHash. The preorder's unique index is now ($ownerId, nameHash). rs-sdk and js-dash-sdk derive nameHash from the findBy function and ownerNameHash from chooseFirst.validWhen, and look a pending preorder up by ($ownerId, nameHash). Tests move from 13/14 to 14/15, gain the front-running regression, and re-pin the name fees (PV14 76,851,000, PV15 62,560,093 credits) and the check_tx contract update fee. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/deletion.md | 12 +- book/src/contract-keywords/generated-from.md | 2 +- .../ownership-and-trading.md | 2 +- .../contract-keywords/property-constraints.md | 2 +- .../src/contract-keywords/refers-to-lookup.md | 2 + book/src/contract-keywords/transient.md | 2 +- .../schema/v3/dpns-contract-documents.json | 31 +++- packages/dpns-contract/src/v3/mod.rs | 23 +-- .../Platform/methods/names/register.spec.ts | 52 +++++- .../Client/Platform/methods/names/register.ts | 30 ++-- packages/rs-dpp/src/system_data_contracts.rs | 81 ++++++--- .../src/execution/check_tx/v0/mod.rs | 2 +- .../v0/mod.rs | 52 ++++-- .../data_triggers/bindings/list/v2/mod.rs | 19 +- .../batch/data_triggers/triggers/dpns/mod.rs | 2 +- .../data_triggers/triggers/dpns/v2/mod.rs | 2 +- .../batch/tests/document/creation.rs | 6 +- .../batch/tests/document/dpns.rs | 13 +- .../batch/tests/document/dpns_v3.rs | 170 ++++++++++++------ .../tests/document/owner_balance_proof.rs | 2 +- .../state_transition/state_transitions/mod.rs | 8 +- .../rs-drive-abci/src/test/helpers/dpns.rs | 2 +- .../strategy_tests/test_cases/voting_tests.rs | 2 +- ...pply_contract_rebuilding_document_types.rs | 12 +- .../drive_abci_validation_versions/mod.rs | 1 + .../drive_abci_validation_versions/v10.rs | 10 +- .../drive_abci_validation_versions/v11.rs | 40 +++++ .../system_data_contract_versions/mod.rs | 1 + .../system_data_contract_versions/v3.rs | 11 +- .../system_data_contract_versions/v4.rs | 22 +++ .../rs-platform-version/src/version/v15.rs | 50 +++++- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 88 +++++---- 32 files changed, 516 insertions(+), 238 deletions(-) create mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs create mode 100644 packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs diff --git a/book/src/contract-keywords/deletion.md b/book/src/contract-keywords/deletion.md index a4647b3f9e8..098d338564d 100644 --- a/book/src/contract-keywords/deletion.md +++ b/book/src/contract-keywords/deletion.md @@ -59,21 +59,17 @@ A commenter may take a comment down at any time. Since `true` is the usual defau "documentsMutable": false, "canBeDeleted": "onlyWhenConsumed", "indices": [ - { - "name": "ownerAndNameHash", - "properties": [{ "$ownerId": "asc" }, { "ownerNameHash": "asc" }], - "unique": true - } + { "name": "saltedHash", "properties": [{ "saltedDomainHash": "asc" }], "unique": true } ], "properties": { - "ownerNameHash": { "type": "bytes", "size": 32, "position": 0 } + "saltedDomainHash": { "type": "bytes", "size": 32, "position": 0 } }, - "required": ["$createdAtBlockHeight", "ownerNameHash"], + "required": ["$createdAtBlockHeight", "saltedDomainHash"], "additionalProperties": false } ``` -A preorder of this type stays until the name registration that reveals it consumes it (the domain's `creatorRefersTo` in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. DPNS v3's own `preorder` declares the same index but `canBeDeleted: true`, so its owner may still delete a preorder it no longer wants, and a `ttl` of an hour, after which the platform deletes it; this example is the stricter variant. +A preorder stays until the name registration that reveals it consumes it (the `preorderSalt` declaration in [Commit and reveal](refers-to-lookup.md#commit-and-reveal)). Its owner can not take it back with a delete. - A delete transition of such a document is refused (`InvalidDocumentTransitionActionError`, 10404), as for `false`. - A consume deletes it as a delete by its owner would, its storage refunded to the owner. diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 1f70f53501e..a07cec16e02 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -42,7 +42,7 @@ A client creates `{ "label": "Bob" }` and the stored document holds `{ "label": The generated property needs no `pattern` of its own. Every value it can hold is what the function generates from params that passed their own patterns: here `label` admits ASCII letters, digits and `-`, so `normalizedLabel` can only ever hold `a` to `z` without `i`, `l` and `o`, digits and `-`. It keeps `maxLength` because it is indexed, and an indexed string declares one of at most 63. -The DPNS `domain` type declares this rule for `normalizedLabel` and `normalizedParentDomainName` from protocol version 14 (DPNS contract v3). Before it, the type's create data trigger checked the same rule. +The DPNS `domain` type declares this rule for `normalizedLabel` and `normalizedParentDomainName` from protocol version 15 (DPNS contract v3). Before it, the type's create data trigger checked the same rule. ## Functions diff --git a/book/src/contract-keywords/ownership-and-trading.md b/book/src/contract-keywords/ownership-and-trading.md index 653a272f94f..9a3ab733a4c 100644 --- a/book/src/contract-keywords/ownership-and-trading.md +++ b/book/src/contract-keywords/ownership-and-trading.md @@ -64,7 +64,7 @@ Whether an owner may give a document to another identity. - The owner sends a transfer transition naming the document, the recipient and a `$revision` one higher than the stored one (`InvalidDocumentRevisionError`, 40106). Only the owner may transfer (`DocumentOwnerIdMismatchError`, 40102). The recipient does not have to agree. - The document gets the recipient as its `$ownerId` and a new `$revision`. A price it was listed at is removed, so a transferred document is no longer for sale. When the type lists `$transferredAt` in `required`, it is set to the block's time, and likewise `$transferredAtBlockHeight` and `$transferredAtCoreBlockHeight` to the block heights. -- A transfer carries no property values, and `$creatorId` keeps naming the identity that created the document. The one change the platform makes itself: from protocol version 13, a DPNS `domain` that is transferred or sold has its `records.identity` pointed at the new owner. +- A transfer carries no property values, and `$creatorId` keeps naming the identity that created the document. The one change the platform makes itself: from protocol version 13, a DPNS `domain` that is transferred or sold has its `records.identity` pointed at the new owner. From protocol version 15 the type's `recordsIdentityIsOwner` rule holds a new name's `records.identity` to its owner until it first changes hands (see [propertyConstraints](property-constraints.md)), so a name can not be transferred or sold in the block that created it. - The document is checked as it will be stored, with its new owner: against the type's unique indexes (`DuplicateUniqueIndexError`, 40105), against a `distinctFrom: "$ownerId"` property (`DocumentPropertyNotDistinctError`, 10419), and against the `propertyConstraints` rules that read what a transfer changes: `$ownerId`, `$transferredAt` and its heights or `$revision`, of the document or, through `$old.`, of the version before the transfer, or a total that depends on the owner (`DocumentPropertyConstraintViolatedError`, 10422; see [propertyConstraints](property-constraints.md#how-it-works)). - On a moderated contract, a transfer to a banned or suspended identity is refused (`ContractModerationCounterpartyBarredError`, 41114). - A transfer of a document past its `ttl` expiry is refused (`DocumentExpiredError`, 40140). diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index ff985ed7186..a2f7d57de99 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -198,7 +198,7 @@ A rule can read when the document was created, last updated and last transferred - `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation. - `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after the listing ends. - `{ "lessThanOrEqual": ["$transferredAt", "endsAt"] }` refuses a transfer or a purchase after it ends. -- `{ "ifThen": [{ "equal": ["$transferredAt", "$createdAt"] }, { "equal": ["records.identity", "$ownerId"] }] }` holds `records.identity` to the owner until the document first changes hands. A create records its block's time as the transfer too, so the condition holds on the create, and a transfer or a purchase in a later block records a later time, so it no longer reads the property. One in the block that created the document records that block's time, so the rule still applies to it: a new owner other than `records.identity` is refused. The DPNS `domain` type's `recordsIdentityIsOwner` (protocol version 14) is this rule. +- `{ "ifThen": [{ "equal": ["$transferredAt", "$createdAt"] }, { "equal": ["records.identity", "$ownerId"] }] }` holds `records.identity` to the owner until the document first changes hands. A create records its block's time as the transfer too, so the condition holds on the create, and a transfer or a purchase in a later block records a later time, so it no longer reads the property. One in the block that created the document records that block's time, so the rule still applies to it: a new owner other than `records.identity` is refused. The DPNS `domain` type's `recordsIdentityIsOwner` (protocol version 15) is this rule. A rule may read one only when the document type records it by listing it in `required`, so every stored document holds it. None takes an `ifAbsent` default, `present` and `absent` refuse them, and an [index-only](index-only.md) type reads none. Each write is judged with the values the stored document ends up with: a create with its block's time and heights for all three events; a replace with the stored creation and transfer values and its block's as the update; a price update with its block's as the update; a transfer or a purchase with its block's as the transfer. diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index 6613d82bae4..9abcfcc5281 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -239,6 +239,8 @@ and the `domain`, which can be transferred, declares on its creator: } ``` +DPNS v3 declares this reveal on its `domain` from protocol version 15, its preorders living an hour, with the `where` entry `"$ownerId": "$ownerId"` and `consume`, so the reveal deletes the preorder it found. + `findBy` finds the writer's own preorder of the name, from an earlier block, by the hash of the name, which every preorder of the name holds, whoever made it. `among` names that property: the preorders holding the same value there are the group the found one is chosen among. `validWhen` says when a preorder of the group counts: it holds, at `ownerNameHash`, the hash of its own owner's id and the name, which only an identity knowing the name can compute (`"$ownerId"` there stands for each preorder's owner). `by` orders the group. When the domain is created, the platform checks the found preorder and then reads: ```sql diff --git a/book/src/contract-keywords/transient.md b/book/src/contract-keywords/transient.md index 934c2c1636a..124b615d1c3 100644 --- a/book/src/contract-keywords/transient.md +++ b/book/src/contract-keywords/transient.md @@ -36,7 +36,7 @@ The DPNS `domain` type of contracts v1 and v2, trimmed to two properties: } ``` -Registering a name took two steps. A `preorder` document first committed to a hash of the salt and the name, and the `domain` document then revealed both. Every domain create carried its 32-byte `preorderSalt`, which the type's create data trigger checked against the preorder. Once the domain was created the salt had done its job, and the stored domain does not hold it. DPNS v3 (protocol version 14) finds the preorder by the hash of the creator and the name instead ([Commit and reveal](refers-to-lookup.md#commit-and-reveal)), and keeps `preorderSalt` declared, transient and unused, so domains stored under v1 and v2 decode. +Registering a name took two steps. A `preorder` document first committed to a hash of the salt and the name, and the `domain` document then revealed both. Every domain create carried its 32-byte `preorderSalt`, which the type's create data trigger checked against the preorder. Once the domain was created the salt had done its job, and the stored domain does not hold it. DPNS v3 (protocol version 15) finds the preorder by its creator and the hash of the name instead, choosing the first valid preorder of the name ([Choosing the first commitment](refers-to-lookup.md#choosing-the-first-commitment-choosefirst)), and keeps `preorderSalt` declared, transient and unused, so domains stored under v1 and v2 decode. ## How it works diff --git a/packages/dpns-contract/schema/v3/dpns-contract-documents.json b/packages/dpns-contract/schema/v3/dpns-contract-documents.json index 66ef162f472..2531953f7e1 100644 --- a/packages/dpns-contract/schema/v3/dpns-contract-documents.json +++ b/packages/dpns-contract/schema/v3/dpns-contract-documents.json @@ -46,10 +46,9 @@ "documentType": "preorder", "findBy": { "$ownerId": "$ownerId", - "ownerNameHash": { + "nameHash": { "function": "sys.hash.sha256d", "params": [ - "$ownerId", "normalizedLabel", { "const": "." @@ -58,7 +57,31 @@ ] } }, - "minimumAgeBlocks": 1, + "where": { + "$ownerId": "$ownerId" + }, + "constraints": { + "minimumAgeBlocks": 1, + "chooseFirst": { + "among": "nameHash", + "validWhen": { + "ownerNameHash": { + "function": "sys.hash.sha256d", + "params": [ + "$ownerId", + "normalizedLabel", + { + "const": "." + }, + "normalizedParentDomainName" + ] + } + }, + "by": [ + "$createdAtBlockHeight" + ] + } + }, "consume": true }, "properties": { @@ -184,7 +207,7 @@ "$ownerId": "asc" }, { - "ownerNameHash": "asc" + "nameHash": "asc" } ], "unique": true diff --git a/packages/dpns-contract/src/v3/mod.rs b/packages/dpns-contract/src/v3/mod.rs index 3ba4fc11765..3a67ec0d5f2 100644 --- a/packages/dpns-contract/src/v3/mod.rs +++ b/packages/dpns-contract/src/v3/mod.rs @@ -6,9 +6,10 @@ pub mod document_types { pub mod preorder { pub mod properties { /// The double SHA-256 of the owner's id, the normalized label, "." and the - /// normalized parent domain name, which the domain's `creatorRefersTo` finds. + /// normalized parent domain name: a preorder holding it counts (`validWhen`). pub const OWNER_NAME_HASH: &str = "ownerNameHash"; - /// The same hash without the owner's id, shared by every preorder of a name. + /// The same hash without the owner's id, shared by every preorder of a name, + /// which the domain's `creatorRefersTo` finds. pub const NAME_HASH: &str = "nameHash"; } } @@ -22,14 +23,16 @@ pub mod document_types { /// `normalizedParentDomainName` are generated from their counterparts /// (`generatedFrom`), `creatorRefersTo` finds the creator's own preorder of the name /// from an earlier block and deletes it (a `findBy` over the creator and the double -/// SHA-256 of the creator's id, the normalized label, "." and the normalized parent, -/// with `minimumAgeBlocks` and `consume`, which needs the preorder to record -/// `$createdAtBlockHeight`), and the `recordsIdentityIsOwner` rule holds a new -/// domain's identity record to its owner. A preorder holds that hash -/// (`ownerNameHash`, unique per owner) and the same hash without the owner -/// (`nameHash`, indexed with the creation height, for telling the first preorder of -/// a name), and lives an hour (`ttl`). The protocol upgrade deletes the v2 preorders -/// and rebuilds the type. Domains can no longer be deleted (`canBeDeleted: false`). +/// SHA-256 of the normalized label, "." and the normalized parent, with +/// `minimumAgeBlocks` and `consume`, which needs the preorder to record +/// `$createdAtBlockHeight`), and refuses it while another identity holds a valid +/// preorder of the name from an earlier block (`chooseFirst` by creation height, a +/// preorder counting when it holds the same hash with its owner's id first), and the +/// `recordsIdentityIsOwner` rule holds a new domain's identity record to its owner. A +/// preorder holds both hashes (`nameHash`, unique per owner and indexed with the +/// creation height, and `ownerNameHash`) and lives an hour (`ttl`). The protocol +/// upgrade deletes the v2 preorders and rebuilds the type. Domains can no longer be +/// deleted (`canBeDeleted: false`). /// The parent domain checks stay in the create trigger. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v3/dpns-contract-documents.json")) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index a8814929a37..bb55eb8bb0c 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -15,8 +15,9 @@ const sha256d = (data: Buffer): Buffer => { }; // The `domain` schema of DPNS v3: the creator reference finds the creator's own -// preorder by the hash of the creator's id, the normalized label, '.' and the -// normalized parent domain name, and a new name's records.identity is its owner +// preorder by the hash of the normalized label, '.' and the normalized parent +// domain name, and chooses the first preorder of the name holding the same hash +// with its owner's id first; a new name's records.identity is its owner const dpnsV3DomainSchema = () => ({ propertyConstraints: { recordsIdentityIsOwner: { @@ -29,11 +30,26 @@ const dpnsV3DomainSchema = () => ({ creatorRefersTo: { findBy: { $ownerId: '$ownerId', - ownerNameHash: { + nameHash: { function: 'sys.hash.sha256d', - params: ['$ownerId', 'normalizedLabel', { const: '.' }, 'normalizedParentDomainName'], + params: ['normalizedLabel', { const: '.' }, 'normalizedParentDomainName'], }, }, + where: { $ownerId: '$ownerId' }, + constraints: { + minimumAgeBlocks: 1, + chooseFirst: { + among: 'nameHash', + validWhen: { + ownerNameHash: { + function: 'sys.hash.sha256d', + params: ['$ownerId', 'normalizedLabel', { const: '.' }, 'normalizedParentDomainName'], + }, + }, + by: ['$createdAtBlockHeight'], + }, + }, + consume: true, }, properties: { preorderSalt: {}, @@ -275,7 +291,31 @@ describe('Platform', () => { expect(platformMock.documents.broadcast.called).to.equal(false); }); - it('should look for a pending preorder by its owner and owner name hash under DPNS v3', async () => { + it('should refuse a creator reference without the chooseFirst hash before preordering', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves({ + getDocumentSchema: () => { + const schema: any = dpnsV3DomainSchema(); + delete schema.creatorRefersTo.constraints; + return schema; + }, + }); + + let error; + try { + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + } catch (e: any) { + error = e; + } + + expect(error.message).to.equal('The DPNS domain\'s creatorRefersTo declares no chooseFirst validWhen function.'); + expect(platformMock.documents.create.called).to.equal(false); + }); + + it('should look for a pending preorder by its owner and name hash under DPNS v3', async () => { const identityId = await generateRandomIdentifier(); identityMock.getId.returns(identityId); platformMock.contracts.get.resolves({ @@ -291,7 +331,7 @@ describe('Platform', () => { { where: [ ['$ownerId', '==', identityId], - ['ownerNameHash', '==', dpnsV3Preorder(identityId).ownerNameHash], + ['nameHash', '==', dpnsV3Preorder(identityId).nameHash], ], limit: 1, }, diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index ff897b68fdb..24ecba72eee 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -50,12 +50,13 @@ export async function register( throw new Error('DPNS is required to register a new name.'); } - // The preorder holds the hashes the platform finds it by. From DPNS v3 the - // contract declares them: the domain's creatorRefersTo finds the creator's own - // preorder by a findBy function over the creator's id, the normalized label, - // '.' and the normalized parent, which the preorder holds under the property - // the function fills, and the same params without the creator's id give its - // nameHash. Before it, the domain reveals a salt: the preorder holds + // The preorder holds the hashes the platform finds and judges it by. From DPNS + // v3 the contract declares them: the domain's creatorRefersTo finds the + // creator's own preorder by a findBy function over the normalized label, '.' + // and the normalized parent, which the preorder holds under the property the + // function fills (nameHash), and its chooseFirst counts a preorder holding, + // under its validWhen property (ownerNameHash), the hash of its owner's id and + // the same values. Before it, the domain reveals a salt: the preorder holds // saltedDomainHash, of the salt and `${normalizedLabel}.${parentDomainName}`, // or the label as sent for a top-level name. The contract is the one the // network stores now, not one cached before an upgrade changed it, whose hash @@ -64,10 +65,19 @@ export async function register( const { contractId } = this.client.getApps().get('dpns'); const dpnsContract = await this.contracts.get(contractId, { skipCache: true }); const domainSchema = dpnsContract ? dpnsContract.getDocumentSchema('domain') : undefined; - const [hashProperty, hashFunction] = Object.entries( - domainSchema?.creatorRefersTo?.findBy ?? {}, + const creatorRefersTo = domainSchema?.creatorRefersTo; + const functionOf = (entries: Record | undefined) => Object.entries( + entries ?? {}, ).find(([, source]: [string, any]) => Array.isArray(source?.params)) ?? []; + const [hashProperty, hashFunction] = functionOf(creatorRefersTo?.findBy); const hashParams: any[] | undefined = (hashFunction as any)?.params; + const [validWhenProperty, validWhenFunction] = functionOf( + creatorRefersTo?.constraints?.chooseFirst?.validWhen, + ); + const validWhenParams: any[] | undefined = (validWhenFunction as any)?.params; + if (Array.isArray(hashParams) && !Array.isArray(validWhenParams)) { + throw new Error('The DPNS domain\'s creatorRefersTo declares no chooseFirst validWhen function.'); + } // From DPNS v3 a new name's records.identity must be its owner // (recordsIdentityIsOwner): refuse another before the preorder is paid @@ -101,8 +111,8 @@ export async function register( const preorderProperties = Array.isArray(hashParams) ? { [hashProperty as string]: hash(Buffer.concat(hashParams.map(paramBytes))), - nameHash: hash(Buffer.concat( - hashParams.filter((param) => param !== '$ownerId').map(paramBytes), + [validWhenProperty as string]: hash(Buffer.concat( + (validWhenParams as any[]).map(paramBytes), )), } : { diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 4c1ca1930c6..289a894f330 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -1165,8 +1165,8 @@ mod dpns_v3_tests { DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::{ - DocumentReferenceKind, DocumentTypeRef, HashFunction, LookupHashKey, LookupKeyParam, - LookupKeySource, + ChooseFirstOrder, DocumentReferenceKind, DocumentTypeRef, HashFunction, LookupChooseFirst, + LookupHashKey, LookupKeyParam, LookupKeySource, }; use crate::util::hash::hash_double; use platform_value::{Identifier, Value}; @@ -1184,11 +1184,20 @@ mod dpns_v3_tests { .unwrap_or_else(|_| panic!("the {name} type")) } + fn name_params() -> Vec { + vec![ + LookupKeyParam::Property("normalizedLabel".to_string()), + LookupKeyParam::Const(".".to_string()), + LookupKeyParam::Property("normalizedParentDomainName".to_string()), + ] + } + /// A domain finds its creator's own preorder by the creator's id and the hash of the - /// creator's id and the normalized name, made in an earlier block, and deletes it. The salt - /// stays declared, unused, so domains written under v1 and v2 decode + /// normalized name, made in an earlier block, chosen first by creation height among the + /// preorders of the name that hold the hash of their own owner's id and the name, and + /// deletes it. The salt stays declared, unused, so domains written under v1 and v2 decode #[test] - fn should_find_the_creators_own_preorder_by_the_hash_of_the_creator_and_the_name() { + fn should_find_the_creators_own_preorder_chosen_first_among_those_of_the_name() { let contract = dpns(); let domain = document_type(&contract, "domain"); let declaration = domain @@ -1200,18 +1209,28 @@ mod dpns_v3_tests { let lookup = declaration.lookup.expect("found by findBy"); assert_eq!(lookup.keys.get("$ownerId"), Some(&LookupKeySource::OwnerId)); assert_eq!( - lookup.keys.get("ownerNameHash"), + lookup.keys.get("nameHash"), Some(&LookupKeySource::Hash(LookupHashKey { function: HashFunction::Sha256d, - params: vec![ - LookupKeyParam::Writer, - LookupKeyParam::Property("normalizedLabel".to_string()), - LookupKeyParam::Const(".".to_string()), - LookupKeyParam::Property("normalizedParentDomainName".to_string()), - ], + params: name_params(), })) ); assert_eq!(lookup.minimum_age_blocks, Some(1)); + assert_eq!( + lookup.choose_first, + Some(LookupChooseFirst { + among: "nameHash".to_string(), + valid_when_property: "ownerNameHash".to_string(), + valid_when: LookupHashKey { + function: HashFunction::Sha256d, + params: [vec![LookupKeyParam::Writer], name_params()].concat(), + }, + by: vec![ChooseFirstOrder { + property: "$createdAtBlockHeight".to_string(), + ascending: true, + }], + }) + ); assert!(lookup.consume); let salt = domain @@ -1224,16 +1243,27 @@ mod dpns_v3_tests { assert!(!domain.required_fields().contains("preorderSalt")); } - /// The preorder of `b0b.dash` by an identity commits to the double SHA-256 of that - /// identity's id, the normalized label, "." and the normalized parent + /// The preorder of `b0b.dash` by an identity holds the double SHA-256 of the normalized + /// label, "." and the normalized parent, and, to count, the same with that identity's id + /// first #[test] - fn should_hash_the_creator_and_the_normalized_name() { + fn should_hash_the_normalized_name_and_the_creator_with_it() { let contract = dpns(); let domain = document_type(&contract, "domain"); - let key = domain + let lookup = domain + .creator_reference() + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + .expect("the creator reference is found by findBy"); + let name_key = domain .creator_reference() .and_then(|target| target.lookup_hash_key()) .expect("the creator reference holds a findBy function"); + let owner_name_key = &lookup + .choose_first + .as_ref() + .expect("the creator reference chooses the first preorder") + .valid_when; let creator = Identifier::from([3; 32]); let data = BTreeMap::from([ ( @@ -1246,17 +1276,21 @@ mod dpns_v3_tests { ), ]); - let (digest, _) = key + let (name_hash, _) = name_key + .key_value(domain, None, creator, &data) + .expect("the name preimage assembles"); + let (owner_name_hash, _) = owner_name_key .key_value(domain, None, creator, &data) - .expect("the preimage assembles"); + .expect("the owner and name preimage assembles"); + assert_eq!(name_hash, hash_double(b"b0b.dash")); let mut preimage = creator.to_buffer().to_vec(); preimage.extend(b"b0b.dash"); - assert_eq!(digest, hash_double(preimage)); + assert_eq!(owner_name_hash, hash_double(preimage)); } - /// A preorder lives an hour, is unique per owner and owner-and-name hash, and is - /// indexed by its name hash and creation height + /// A preorder lives an hour, is unique per owner and name hash, and is indexed by its + /// name hash and creation height #[test] fn should_expire_preorders_after_an_hour_and_index_them_by_name_hash() { let contract = dpns(); @@ -1279,10 +1313,7 @@ mod dpns_v3_tests { }; assert_eq!( index_properties("ownerAndNameHash"), - ( - vec!["$ownerId".to_string(), "ownerNameHash".to_string()], - true - ) + (vec!["$ownerId".to_string(), "nameHash".to_string()], true) ); assert_eq!( index_properties("nameHashAndHeight"), diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 01e2abba3d5..614b147462b 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1968,7 +1968,7 @@ mod tests { // contract bytes; the DPNS v3 preorder type the update copies in has its own // (its per-owner index, its non-unique `nameHashAndHeight` index registered at // 0.01 Dash, `$createdAt` and `$createdAtBlockHeight` required, a ttl) - 28003229100 + 28003227100 ); let check_result = platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 4c6528abd44..c41baa4ca1a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -121,6 +121,10 @@ impl Platform { self.transition_to_version_14(block_info, transaction, platform_version)?; } + if previous_protocol_version < 15 && platform_version.protocol_version >= 15 { + self.transition_to_version_15(block_info, transaction, platform_version)?; + } + Ok(()) } @@ -705,12 +709,10 @@ impl Platform { /// When transitioning to version 14 we re-store the DashPay contract whose /// v2 schema adds the optional public payment address fields to the /// `profile` document type (DIP-33), the withdrawals contract whose v2 - /// schema admits the terminal FAILED value of the `status` property, the - /// DPNS contract whose v3 schema checks a domain create with keywords - /// instead of most of its data trigger, and register the app-connect - /// contract that carries the wallet-to-app login handshake and the - /// moderation charters contract that elected moderation teams apply - /// through. + /// schema admits the terminal FAILED value of the `status` property, and + /// register the app-connect contract that carries the wallet-to-app login + /// handshake and the moderation charters contract that elected moderation + /// teams apply through. fn transition_to_version_14( &self, block_info: &BlockInfo, @@ -752,19 +754,6 @@ impl Platform { platform_version, )?; - // DPNS contract v3 (v14 note 92): its preorders are unique per owner, which no - // contract update can turn a unique index into, so the `preorder` type is rebuilt - // and every preorder made before this block is deleted, unrefunded. - let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; - - self.drive.apply_contract_rebuilding_document_types( - &dpns_contract, - &[preorder::NAME], - block_info, - transaction, - platform_version, - )?; - // App-connect contract: the wallet's encrypted login response gets one system // contract id on every network from this version. Fresh // chains register it at genesis (`create_genesis_state` v2). @@ -905,6 +894,31 @@ impl Platform { Ok(()) } + + /// When transitioning to version 15 we re-store the DPNS contract whose v3 + /// schema checks a domain create with keywords instead of most of its data + /// trigger. + fn transition_to_version_15( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // DPNS contract v3 (v15 note 1): its preorders are unsalted, one per owner and name, + // which no contract update can turn a unique index into, so the `preorder` type is + // rebuilt and every preorder made before this block is deleted, unrefunded. + let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; + + self.drive.apply_contract_rebuilding_document_types( + &dpns_contract, + &[preorder::NAME], + block_info, + transaction, + platform_version, + )?; + + Ok(()) + } } #[cfg(test)] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs index a115f789e6d..4b7963db392 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs @@ -17,10 +17,7 @@ use drive::state_transition_action::batch::batched_transition::document_transiti /// v2 (PROTOCOL_VERSION_14): DashPay `profile` documents gain Create and /// Replace triggers enforcing the DIP-33 payment-address type byte /// (`0x00` P2PKH / `0x01` P2SH) that the schema vocabulary cannot express. -/// DPNS `domain` Replace and Delete lose their reject bindings: DPNS v3 -/// refuses both with `documentsMutable` and `canBeDeleted` false. Its Create -/// binding stays for the parent domain checks (`create_domain_data_trigger` -/// v2). Everything else is unchanged from v1. +/// Everything else is unchanged from v1. /// /// # Returns /// @@ -38,6 +35,20 @@ pub(super) fn data_trigger_bindings_list_v2() -> Result create_domain_data_trigger_v1(document_transition, context, platform_version), - // PROTOCOL_VERSION_14+: only the parent domain checks; DPNS v3's schema + // PROTOCOL_VERSION_15+: only the parent domain checks; DPNS v3's schema // keywords check the normalized names, the preorder and the records. 2 => create_domain_data_trigger_v2(document_transition, context, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs index c795f661321..a4dd1c9ac42 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v2/mod.rs @@ -26,7 +26,7 @@ use crate::execution::validation::state_transition::batch::data_triggers::{ DataTriggerExecutionContext, DataTriggerExecutionResult, }; -/// The DPNS `domain` create trigger from protocol version 14, when DPNS v3's +/// The DPNS `domain` create trigger from protocol version 15, when DPNS v3's /// schema keywords check the rest of a create: the normalized names are /// generated from their counterparts (`generatedFrom`), the preorder is the /// creator's own from an earlier block and the create deletes it diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 9f53abd88bb..157840f891c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -65,7 +65,7 @@ mod creation_tests { use crate::execution::validation::state_transition::tests::{create_card_game_external_token_contract_with_owner_identity, create_card_game_internal_token_contract_with_owner_identity_transfer_tokens, create_token_contract_with_owner_identity}; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::SuccessfulExecution; - /// The block after the one holding the preorders: from protocol version 14 a domain + /// The block after the one holding the preorders: from protocol version 15 a domain /// reveals a preorder made in an earlier block. fn block_after_the_preorders() -> BlockInfo { BlockInfo::default_with_height(1) @@ -3648,8 +3648,8 @@ mod creation_tests { let SuccessfulExecution { fee_result, .. } = result else { panic!("expected the contender to join, got {result:?}"); }; - // From protocol version 14 the join consumes the contender's preorder, whose storage - // is refunded to it + // From protocol version 15 the join consumes the contender's preorder, which refunds + // nothing (it expires), though the check reads the refund to hold either way let preorder_refund = fee_result .fee_refunds .calculate_refunds_amount_for_identity(contender.id()) 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 0e3e4a4e098..b13506a59a0 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 @@ -1183,7 +1183,7 @@ mod dpns_username_transfer_tests { .expect("expected domain batch serialized state transition"); // The preorder and the domain in consecutive blocks, since from protocol - // version 14 a domain reveals a preorder made in an earlier block, and both + // version 15 a domain reveals a preorder made in an earlier block, and both // before the transfers and sales of the tests below, which run at +3000: a // transfer at the domain's own creation time would be judged as its create let last_block_time_ms = platform_state @@ -2883,8 +2883,9 @@ mod dpns_username_transfer_tests { /// now change hands. /// /// `Replace` is stopped before data triggers run because the domain - /// document type is not mutable. From protocol version 14 `Delete` is too: - /// DPNS v3 sets `canBeDeleted: false`, and the bindings no longer reject it. + /// document type is not mutable. From protocol version 15 `Delete` is too: + /// DPNS v3 sets `canBeDeleted: false`, which refuses it before the reject + /// binding would. #[tokio::test] async fn test_dpns_username_replace_and_delete_still_rejected() { run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( @@ -2893,11 +2894,11 @@ mod dpns_username_transfer_tests { .await; } - /// PROTOCOL_VERSION_13: DPNS v2's `domain` sets `canBeDeleted: true`, so + /// PROTOCOL_VERSION_14: DPNS v2's `domain` sets `canBeDeleted: true`, so /// `Delete` is stopped only by the reject data trigger. #[tokio::test] - async fn should_reject_dpns_username_replace_and_delete_at_protocol_version_13() { - run_dpns_username_replace_and_delete_still_rejected_at_protocol_version(13).await; + async fn should_reject_dpns_username_replace_and_delete_at_protocol_version_14() { + run_dpns_username_replace_and_delete_still_rejected_at_protocol_version(14).await; } async fn run_dpns_username_replace_and_delete_still_rejected_at_protocol_version( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index c6797f554c0..dd8a008d65b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -1,12 +1,14 @@ -//! The DPNS contract v3 (protocol version 14) through the full ABCI pipeline. A +//! The DPNS contract v3 (protocol version 15) through the full ABCI pipeline. A //! domain create is checked by schema keywords where the create data trigger //! checked it before: `normalizedLabel` and `normalizedParentDomainName` are //! generated from their counterparts, `creatorRefersTo` finds the creator's own -//! preorder of the name (by the hash of the creator's id and the normalized name) -//! from an earlier block and deletes it, and the `recordsIdentityIsOwner` rule -//! holds a new name's identity record to its owner. A preorder carries no salt -//! from v3 and lives an hour (`ttl`). -//! The trigger keeps only the parent domain checks. Protocol version 13 keeps +//! preorder of the name (by the creator and the hash of the normalized name) from +//! an earlier block, refuses it while another identity holds a valid preorder of +//! the name from an earlier block (`chooseFirst`, a preorder counting when it holds +//! the hash of its own owner's id and the name), and deletes it, and the +//! `recordsIdentityIsOwner` rule holds a new name's identity record to its owner. A +//! preorder carries no salt from v3 and lives an hour (`ttl`). +//! The trigger keeps only the parent domain checks. Protocol version 14 keeps //! DPNS v2 and the whole trigger, pinned beside each case. use super::*; @@ -184,7 +186,7 @@ mod dpns_v3_tests { /// with `values` set over the random required ones and `absent` /// removed, and returns it with the result. The transition carries the /// document's properties exactly as set: the client builder would - /// regenerate a wrong `generatedFrom` value from protocol version 14. + /// regenerate a wrong `generatedFrom` value from protocol version 14 on. async fn create( &mut self, who: Who, @@ -392,11 +394,11 @@ mod dpns_v3_tests { .pop() } - /// Runs the protocol version 14 activation at block `height` on a - /// chain at protocol version 13, as the first block of version 14 does. - fn activate_protocol_version_14(&mut self, height: u64) { - let platform_version_14 = - PlatformVersion::get(14).expect("expected platform version 14"); + /// Runs the protocol version 15 activation at block `height` on a + /// chain at protocol version 14, as the first block of version 15 does. + fn activate_protocol_version_15(&mut self, height: u64) { + let platform_version_15 = + PlatformVersion::get(15).expect("expected platform version 15"); let platform_state = self.platform.state.load(); let transaction = self.platform.drive.grove.start_transaction(); self.platform @@ -404,22 +406,22 @@ mod dpns_v3_tests { &platform_state, &block(height), &transaction, - 13, - platform_version_14, + 14, + platform_version_15, ) - .expect("expected the transition to protocol version 14 to succeed"); + .expect("expected the transition to protocol version 15 to succeed"); self.platform .drive .grove .commit_transaction(transaction) .unwrap() - .expect("expected to commit the transition to protocol version 14"); + .expect("expected to commit the transition to protocol version 15"); drop(platform_state); let mut upgraded_state = self.platform.state.load().as_ref().clone(); - upgraded_state.set_current_protocol_version_in_consensus(14); - upgraded_state.set_next_epoch_protocol_version(14); + upgraded_state.set_current_protocol_version_in_consensus(15); + upgraded_state.set_next_epoch_protocol_version(15); self.platform.state.store(Arc::new(upgraded_state)); - self.platform_version = platform_version_14; + self.platform_version = platform_version_15; } } @@ -628,8 +630,8 @@ mod dpns_v3_tests { .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) .await; - // Bob read the name off Alice's domain create and races it: his reveal hashes his - // own id and looks among his own preorders, so it finds nothing + // Bob read the name off Alice's domain create and races it: his reveal looks among + // his own preorders, so it finds nothing let (_, result) = fixture .domain(Who::Bob, salt, "quantum7", "dash", &[], &[], REVEAL_HEIGHT) .await; @@ -641,10 +643,10 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&preorder).is_some()); } - /// Preorders are unique per owner and their hash binds the owner, so a copy of - /// someone's preorder is harmless: stored first, it does not stop the owner's own - /// preorder, and the copier, who learns the name from the owner's reveal, cannot - /// reveal it with the copy. + /// Preorders are unique per owner and count only when their `ownerNameHash` binds + /// their own owner, so a copy of someone's preorder is harmless: stored in an earlier + /// block, it neither stops the owner's own preorder nor precedes it, and the copier, + /// who learns the name from the owner's reveal, cannot reveal it with the copy. #[tokio::test] async fn should_neither_block_nor_reveal_a_name_with_a_copied_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); @@ -657,9 +659,9 @@ mod dpns_v3_tests { .map(|(property, value)| (property.as_str(), value.clone())) .collect(); - // Bob copies Alice's pending commitment, and his copy lands first + // Bob copies Alice's pending commitment, and his copy lands a block earlier let (copy, result) = fixture - .create(Who::Bob, "preorder", &copied, &[], PREORDER_HEIGHT) + .create(Who::Bob, "preorder", &copied, &[], PREORDER_HEIGHT - 1) .await; assert_matches!( result, @@ -707,7 +709,7 @@ mod dpns_v3_tests { ); } - /// Without a salt, an owner's preorders of one name share their `ownerNameHash`, so + /// Without a salt, an owner's preorders of one name share their `nameHash`, so /// an owner holds one pending preorder per name: a second is refused while the first /// stands, and the first reveals the name #[tokio::test] @@ -731,7 +733,7 @@ mod dpns_v3_tests { assert_matches!( refusal(&result), ConsensusError::StateError(StateError::DuplicateUniqueIndexError(e)) - if e.duplicating_properties() == &vec!["$ownerId".to_string(), "ownerNameHash".to_string()] + if e.duplicating_properties() == &vec!["$ownerId".to_string(), "nameHash".to_string()] ); let (_, result) = fixture @@ -752,6 +754,64 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&preorder).is_none()); } + /// The race a full block opens: Bob reads the name off Alice's pending domain create, + /// preorders it himself, his preorder valid as he knows the name, and reveals before + /// Alice's create lands. Alice's preorder is older and valid, so his reveal is refused + /// and hers then registers the name. + #[tokio::test] + async fn should_refuse_a_reveal_preceded_by_another_identitys_valid_preorder() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let salt: [u8; 32] = fixture.rng.gen(); + let alice_preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + let bob_preorder = fixture + .preorder(Who::Bob, salt, "quantum7", "dash", REVEAL_HEIGHT) + .await; + + let (_, result) = fixture + .domain( + Who::Bob, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT + 1, + ) + .await; + assert_matches!( + refusal(&result), + ConsensusError::StateError(StateError::ReferencedDocumentPrecededError(_)) + ); + assert!(fixture.name("quantum7").is_none()); + assert!(fixture.stored_preorder(&bob_preorder).is_some()); + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT + 1, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!( + fixture + .name("quantum7") + .expect("expected the name") + .owner_id(), + fixture.id(Who::Alice) + ); + assert!(fixture.stored_preorder(&alice_preorder).is_none()); + } + #[tokio::test] async fn should_refuse_a_name_without_its_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); @@ -819,7 +879,7 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&preorder).is_some()); } - /// The create trigger keeps the parent domain checks at protocol version 14: + /// The create trigger keeps the parent domain checks at protocol version 15: /// each case reveals a preorder made for it, so only the trigger refuses it. #[tokio::test] async fn should_keep_the_parent_domain_checks_in_the_create_trigger() { @@ -898,12 +958,12 @@ mod dpns_v3_tests { assert_ne!(fixture.id(Who::Alice), dpns_contract::OWNER_ID); } - /// Before protocol version 14 the whole create trigger runs on DPNS v2: a + /// Before protocol version 15 the whole create trigger runs on DPNS v2: a /// missing preorder and a wrong normalized label are its refusals, and it /// neither deletes the preorder nor reads the identity record. #[tokio::test] - async fn should_keep_the_create_trigger_at_protocol_version_13() { - let mut fixture = DpnsFixture::new(13); + async fn should_keep_the_create_trigger_at_protocol_version_14() { + let mut fixture = DpnsFixture::new(14); let salt: [u8; 32] = fixture.rng.gen(); let (_, result) = fixture @@ -1010,14 +1070,14 @@ mod dpns_v3_tests { ); } - /// A chain upgraded from protocol version 13: DPNS is re-stored as v3, every + /// A chain upgraded from protocol version 14: DPNS is re-stored as v3, every /// domain written under v2 decodes unchanged, and the `preorder` type is /// rebuilt: every preorder made before the upgrade is gone, a pending one can /// no longer be revealed, and the type is laid out exactly as on a chain born - /// at protocol version 14. + /// at protocol version 15. #[tokio::test] async fn should_decode_v2_domains_and_rebuild_the_preorders_at_the_upgrade() { - let mut fixture = DpnsFixture::new(13); + let mut fixture = DpnsFixture::new(14); let (_, registered, result) = fixture.register(Who::Bob, "quantum7", &[], &[]).await; assert_matches!( @@ -1038,7 +1098,7 @@ mod dpns_v3_tests { .expect("expected the domain document type") .documents_can_be_deleted()); - fixture.activate_protocol_version_14(20); + fixture.activate_protocol_version_15(20); let v3_dpns = fixture.dpns(); assert!(!v3_dpns @@ -1057,16 +1117,16 @@ mod dpns_v3_tests { ); // The rebuilt preorder type holds nothing and is laid out as a chain born at - // protocol version 14 lays it out + // protocol version 15 lays it out assert!(fixture.stored_preorder(&pending).is_none()); // A genesis state is all such a chain needs: no identities - let born_at_14 = TestPlatformBuilder::new() - .with_initial_protocol_version(14) + let born_at_15 = TestPlatformBuilder::new() + .with_initial_protocol_version(15) .build_with_mock_rpc() .set_genesis_state(); assert_eq!( preorder_type_layout(&fixture.platform, fixture.platform_version), - preorder_type_layout(&born_at_14, fixture.platform_version) + preorder_type_layout(&born_at_15, fixture.platform_version) ); // Alice's pending preorder is gone, so she preorders again @@ -1098,7 +1158,7 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&preorder).is_none()); } - /// A chain born at protocol version 14 stores DPNS v3 and still inserts + /// A chain born at protocol version 15 stores DPNS v3 and still inserts /// the `dash` top-level domain, which `allowSubdomains` and whose owner is /// the DPNS contract owner. #[tokio::test] @@ -1131,7 +1191,7 @@ mod dpns_v3_tests { .expect("expected the subdomain rule")); } - /// A contested name at protocol version 14: the create that joins the + /// A contested name at protocol version 15: the create that joins the /// contest is judged by the rules and reveals and consumes its preorder /// there, though the name is only stored once the contest is awarded. #[tokio::test] @@ -1182,7 +1242,7 @@ mod dpns_v3_tests { ); } - /// Two contenders open a contest on a name at protocol version 14, each + /// Two contenders open a contest on a name at protocol version 15, each /// consuming its own preorder, and the award stores the winner's name. #[tokio::test] async fn should_award_a_contested_name_to_its_winner() { @@ -1206,10 +1266,10 @@ mod dpns_v3_tests { ) .await; for preorder in [&preorder_1, &preorder_2] { - let owner_name_hash = preorder + let name_hash = preorder .properties() - .get("ownerNameHash") - .expect("expected the preorder's owner and name hash") + .get("nameHash") + .expect("expected the preorder's name hash") .clone(); assert!( query_dpns( @@ -1220,7 +1280,7 @@ mod dpns_v3_tests { "$ownerId", Value::Identifier(preorder.owner_id().to_buffer()) ), - ("ownerNameHash", owner_name_hash), + ("nameHash", name_hash), ], platform_version, ) @@ -1292,13 +1352,13 @@ mod dpns_v3_tests { } /// What a client pays for a name: the preorder and the domain create at protocol - /// version 13 (create trigger, preorder kept, stored for good) and at 14 (keywords, a + /// version 14 (create trigger, preorder kept, stored for good) and at 15 (keywords, a /// preorder living an hour and priced for that time, consumed by the create without a /// refund). Printed for the record and pinned. #[tokio::test] - async fn should_charge_a_name_at_protocol_versions_13_and_14() { + async fn should_charge_a_name_at_protocol_versions_14_and_15() { let mut measured = vec![]; - for protocol_version in [13, 14] { + for protocol_version in [14, 15] { let mut fixture = DpnsFixture::new(protocol_version); let alice = fixture.id(Who::Alice); let salt: [u8; 32] = fixture.rng.gen(); @@ -1347,10 +1407,10 @@ mod dpns_v3_tests { assert_eq!( measured, vec![ - (1_194_500, 31_131_000, 0), - (1_876_280, 42_660_000, 0), - (4_914_640, 11_558_163, 0), - (3_394_500, 42_687_000, 0) + (1_159_720, 31_158_000, 0), + (1_846_280, 42_687_000, 0), + (4_910_640, 11_558_153, 0), + (3_404_300, 42_687_000, 0) ] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs index 6209b748f88..db75d4f2058 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs @@ -627,7 +627,7 @@ async fn should_prove_the_owner_balance_next_to_a_contested_document() { .await .expect("expected to sign the contested create"); - // The block after the preorder's: from protocol version 14 a domain reveals a preorder + // The block after the preorder's: from protocol version 15 a domain reveals a preorder // made in an earlier block process_and_commit_at( &platform, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 34102575c16..8a675e2205d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -1244,7 +1244,7 @@ pub(in crate::execution) mod tests { assert!(!domain.documents_mutable()); // Deletion is refused by a data trigger on DPNS v2, by `canBeDeleted: false` - // from DPNS v3 (protocol version 14) + // from DPNS v3 (protocol version 15) assert_eq!( domain.documents_can_be_deleted(), platform_version.system_data_contracts.dpns < 3 @@ -1504,7 +1504,7 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); - // One block after the preorders: from protocol version 14 a domain reveals a + // One block after the preorders: from protocol version 15 a domain reveals a // preorder made in an earlier block let processing_result = platform .platform @@ -1821,7 +1821,7 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); - // One block after the preorders: from protocol version 14 a domain reveals a + // One block after the preorders: from protocol version 15 a domain reveals a // preorder made in an earlier block let processing_result = platform .platform @@ -2085,7 +2085,7 @@ pub(in crate::execution) mod tests { let transaction = platform.drive.grove.start_transaction(); - // One block after the preorder: from protocol version 14 a domain reveals a + // One block after the preorder: from protocol version 15 a domain reveals a // preorder made in an earlier block let processing_result = platform .platform diff --git a/packages/rs-drive-abci/src/test/helpers/dpns.rs b/packages/rs-drive-abci/src/test/helpers/dpns.rs index 1c4a7c93f10..fb556c3b2f9 100644 --- a/packages/rs-drive-abci/src/test/helpers/dpns.rs +++ b/packages/rs-drive-abci/src/test/helpers/dpns.rs @@ -6,7 +6,7 @@ use dpp::version::PlatformVersion; /// The hash properties a DPNS preorder by `owner_id` holds for the name /// `normalized_label` under the normalized `parent`, as the DPNS system contract -/// of `platform_version` declares them. From DPNS v3 (protocol version 14): +/// of `platform_version` declares them. From DPNS v3 (protocol version 15): /// `ownerNameHash`, the double SHA-256 of the owner's id, the normalized label, /// `"."` and the parent, and `nameHash`, the same without the owner; `salt` is /// not read. Before it: `saltedDomainHash`, of `salt` and diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index f2463d70bd7..99a5d9e131e 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -46,7 +46,7 @@ mod tests { const STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB /// The `preorder` op of `owner_id` for `label` under `dash` salted with `salt`, hashed as - /// the DPNS contract of `platform_version` declares, and the `domain` op revealing it. From protocol version 14 a domain must reveal its owner's + /// the DPNS contract of `platform_version` declares, and the `domain` op revealing it. From protocol version 15 a domain must reveal its owner's /// preorder made in an earlier block, so the preorder op runs in a block before the domain op. fn dpns_preorder_and_domain_ops( dpns_contract: &DataContract, diff --git a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs index e10453ed2ab..b6572e5896a 100644 --- a/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs +++ b/packages/rs-drive/src/drive/contract/migration/apply_contract_rebuilding_document_types.rs @@ -44,7 +44,7 @@ impl Drive { /// For a protocol upgrade that changes a system contract's document type in a way a /// contract update cannot follow, such as replacing a unique index; the documents deleted /// are not refunded. Runs once, on the first block of the protocol version that ships the - /// new contract (DPNS v3's `preorder` at protocol version 14). Like an update, it leaves + /// new contract (DPNS v3's `preorder` at protocol version 15). Like an update, it leaves /// the rewritten contract in the block cache. pub fn apply_contract_rebuilding_document_types( &self, @@ -175,10 +175,10 @@ mod tests { #[test] fn should_serve_the_rebuilt_contract_to_reads_in_the_block() { - let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); + let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14"); let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(platform_version_13)); - let dpns_v2 = load_system_data_contract(SystemDataContract::DPNS, platform_version_13) + let drive = setup_drive_with_initial_state_structure(Some(platform_version_14)); + let dpns_v2 = load_system_data_contract(SystemDataContract::DPNS, platform_version_14) .expect("DPNS v2 loads"); drive .apply_contract( @@ -187,13 +187,13 @@ mod tests { true, None, None, - platform_version_13, + platform_version_14, ) .expect("DPNS v2 is stored"); let dpns_id = dpns_v2.id().to_buffer(); // A committed-state read puts DPNS v2 in the global cache drive - .get_contract_with_fetch_info(dpns_id, true, None, platform_version_13) + .get_contract_with_fetch_info(dpns_id, true, None, platform_version_14) .expect("DPNS is read") .expect("DPNS is stored"); diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index fc61961a8a5..8af14768f5e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -1,5 +1,6 @@ pub mod v1; pub mod v10; +pub mod v11; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 165838a981d..28bf44e3d62 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -222,10 +222,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { // PROTOCOL_VERSION_14: v2 adds DashPay `profile` // Create/Replace triggers enforcing the DIP-33 - // payment-address type byte (0x00 P2PKH / 0x01 P2SH), - // and drops the reject bindings on DPNS `domain` Replace - // and Delete, which DPNS v3 refuses with - // `documentsMutable` and `canBeDeleted` false. + // payment-address type byte (0x00 P2PKH / 0x01 P2SH). // It keeps v1's PROTOCOL_VERSION_13 change: no reject // bindings for Transfer, Purchase and UpdatePrice on // DPNS `domain` documents (username transfers/sales). @@ -238,10 +235,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // byte-identical to PV11 (don't bill). create_contact_request_data_trigger: 1, validate_profile_payment_addresses_data_trigger: 0, - // PROTOCOL_VERSION_14: v2 keeps only the parent - // domain checks; DPNS v3's schema keywords check the - // rest of a domain create. - create_domain_data_trigger: 2, + create_domain_data_trigger: 1, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, create_masternode_reward_shares_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs new file mode 100644 index 00000000000..02afeb058bc --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -0,0 +1,40 @@ +use crate::version::drive_abci_versions::drive_abci_validation_versions::{ + DriveAbciDocumentsStateTransitionValidationVersions, + DriveAbciStateTransitionValidationVersions, DriveAbciValidationDataTriggerAndBindingVersions, + DriveAbciValidationDataTriggerVersions, DriveAbciValidationVersions, +}; + +use super::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; + +/// Protocol v15 validation versions. +/// +/// `create_domain_data_trigger` 2 keeps only the parent domain checks of a DPNS +/// domain create: the DPNS contract v3 (`SYSTEM_DATA_CONTRACT_VERSIONS_V4`) +/// checks the normalized names, the preorder and the identity record with +/// schema keywords. +pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = + DriveAbciValidationVersions { + state_transitions: DriveAbciStateTransitionValidationVersions { + batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { + data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { + triggers: DriveAbciValidationDataTriggerVersions { + create_domain_data_trigger: 2, + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + .state_transitions + .batch_state_transition + .data_triggers + .triggers + }, + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + .state_transitions + .batch_state_transition + .data_triggers + }, + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + .state_transitions + .batch_state_transition + }, + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10.state_transitions + }, + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs index a8214a67d46..1fe2f9352a6 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs @@ -1,6 +1,7 @@ pub mod v1; pub mod v2; pub mod v3; +pub mod v4; use crate::version::FeatureVersion; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index fff66d942ca..5bdeac3b0b4 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -8,13 +8,8 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // withdrawals whose asset unlock Core can never mine. The token history // contract v2 admits the value 2 (OncePerIdentity) of the claim document's // `distributionType`, written for once-per-identity distribution claims. -// The DPNS contract v3 checks a domain create with schema keywords instead of -// most of its data trigger: generated normalized names, the reveal and -// deletion of the writer's own preorder from an earlier block (preorders unique -// per owner, their hash binding the owner), and the new domain's identity record -// held to its owner. Domains can no longer be deleted. v2 (dashpay: 1, -// withdrawals: 1, token_history: 1, dpns: 2) remains for PROTOCOL_VERSION_13 -// chain replay. +// v2 (dashpay: 1, withdrawals: 1, token_history: 1) remains for +// PROTOCOL_VERSION_13 chain replay. // // The app-connect contract (app_connect: 1) also activates with // PROTOCOL_VERSION_14: it is registered at genesis from that version on and @@ -29,7 +24,7 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, - dpns: 3, + dpns: 2, dashpay: 2, masternode_reward_shares: 1, wallet: 1, diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs new file mode 100644 index 00000000000..63fdcd01b8e --- /dev/null +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs @@ -0,0 +1,22 @@ +use crate::version::system_data_contract_versions::SystemDataContractVersions; + +// PROTOCOL_VERSION_15: the DPNS contract v3 checks a domain create with schema +// keywords instead of most of its data trigger: generated normalized names, the +// reveal and deletion of the writer's own preorder from an earlier block +// (preorders unsalted, one per owner and name, the first valid one of a name +// chosen), and the new domain's identity record held to its owner. Domains can +// no longer be deleted. v3 (dpns: 2) remains for PROTOCOL_VERSION_14 chain +// replay. +pub const SYSTEM_DATA_CONTRACT_VERSIONS_V4: SystemDataContractVersions = + SystemDataContractVersions { + withdrawals: 2, + dpns: 3, + dashpay: 2, + masternode_reward_shares: 1, + wallet: 1, + token_history: 2, + keyword_search: 1, + document_history: 1, + app_connect: 1, + moderation_charters: 1, + }; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 151896fed16..dd0db4d66cd 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -18,13 +18,13 @@ use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::D use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; -use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_validation_versions::v11::DRIVE_ABCI_VALIDATION_VERSIONS_V11; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; use crate::version::drive_abci_versions::DriveAbciVersion; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::fee::v3::FEE_VERSION3; use crate::version::protocol_version::PlatformVersion; -use crate::version::system_data_contract_versions::v3::SYSTEM_DATA_CONTRACT_VERSIONS_V3; +use crate::version::system_data_contract_versions::v4::SYSTEM_DATA_CONTRACT_VERSIONS_V4; use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; use crate::version::ProtocolVersion; @@ -35,13 +35,55 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// version structs, no behavior change. Each change that needs this gate /// lands in its own follow-up and bumps the component table it consumes here; /// keeping v15 == v14 until then lets mixed-version validators agree. +/// +/// Changes from v14: +/// +/// 1. **DPNS v3 checks a name's create with keywords**: +/// `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects DPNS contract v3, loaded at +/// genesis (which still inserts the `dash` top-level domain). Its `domain` +/// generates `normalizedLabel` and `normalizedParentDomainName` with +/// `sys.stringTransformations.homographSafeASCII` (a value sent that differs +/// is `DocumentPropertyNotGeneratedError`, 10424). Its `creatorRefersTo` finds +/// the creator's own `preorder` of the name, through `findBy` `$ownerId` and +/// `nameHash` (the sha256d of the normalized label, `"."` and the normalized +/// parent), from an earlier block (`minimumAgeBlocks: 1`), and deletes it +/// (`consume`, beside the `where` entry `$ownerId`); not found is 40120, too +/// young 40142. Its `chooseFirst` refuses the create while another identity +/// holds an unexpired preorder of the name from an earlier block that holds, +/// at `ownerNameHash`, the sha256d of its own owner's id and the name +/// (`ReferencedDocumentPrecededError`, 40149), and the found preorder must +/// hold that hash with the creator's id (else 40120). So a preorder copied +/// from a pending one counts for nobody, and an identity that learns a name +/// from a pending reveal and preorders it itself comes after the revealer. +/// A preorder carries no salt: it holds both hashes, is unique per owner and +/// name (`ownerAndNameHash` over `$ownerId` and `nameHash`, replacing +/// `saltedHash`), is indexed by `nameHash` and creation height +/// (`nameHashAndHeight`) and lives an hour (`ttl` 3600, so it refunds nothing +/// when consumed). The `domain` keeps `preorderSalt` declared, transient and +/// no longer required, so domains written under v1 and v2 decode. The rule +/// `recordsIdentityIsOwner` (`$transferredAt` equal to `$createdAt` implies +/// `records.identity` equal to `$ownerId`) holds a new name's identity record +/// to its owner (10422); a transfer or a purchase in a later block no longer +/// reads it, and one in the block that created the name is refused. The +/// `domain` sets `canBeDeleted: false`; its property positions, transient +/// list and stored encoding are v2's. `transition_to_version_15` re-stores +/// DPNS through `Drive::apply_contract_rebuilding_document_types`: every +/// preorder made before the upgrade is deleted, unrefunded, and the +/// `preorder` type is rebuilt as a contract update creates a type it adds, +/// the layout a chain born at this version has. +/// `DRIVE_ABCI_VALIDATION_VERSIONS_V11` selects `create_domain_data_trigger` +/// 2, which keeps only the parent domain checks: a top-level domain only by +/// the contract owner, the parent present, no subdomains under a name, and +/// the parent's subdomain rule. The data trigger bindings stay at 2: their +/// `domain` Replace and Delete rejects are never reached, as +/// `documentsMutable` and `canBeDeleted` false refuse both first (10404). pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V10, - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: create_domain_data_trigger 2 (note 1) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -62,7 +104,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 1) fee_version: FEE_VERSION3, system_limits: SYSTEM_LIMITS_V4, consensus: ConsensusVersions { diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 45646ea1d4a..7ba196955e0 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -18,9 +18,7 @@ use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; -use dpp::data_contract::document_type::{ - DocumentType, DocumentTypeRef, LookupHashKey, LookupKeyParam, -}; +use dpp::data_contract::document_type::{DocumentType, DocumentTypeRef, LookupHashKey}; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; @@ -57,12 +55,9 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } -/// The property a DPNS v3 preorder holds the hash of its owner and the name under, as the -/// contract names it. Spelled here rather than read from the bundled DPNS contract, which the -/// SDK builds only with that contract's feature. -const OWNER_NAME_HASH: &str = "ownerNameHash"; - -/// The property a DPNS v3 preorder holds the hash of the name alone under. +/// The property a DPNS v3 preorder holds the hash of the name alone under, as the contract +/// names it, which the `ownerAndNameHash` index keys with its owner. Spelled here rather than +/// read from the bundled DPNS contract, which the SDK builds only with that contract's feature. const NAME_HASH: &str = "nameHash"; /// Hash a buffer twice using SHA256 (double SHA256) @@ -76,13 +71,14 @@ fn hash_double(data: Vec) -> [u8; 32] { /// The hash properties a preorder by `owner_id` holds for a domain carrying /// `domain_properties`, as the DPNS contract the network runs declares them. From DPNS v3 /// the domain's `creatorRefersTo` finds the creator's own preorder through a `findBy` -/// function over the creator's id, the normalized label, `"."` and the normalized parent: -/// the preorder holds that hash under the property the function fills (`ownerNameHash`), -/// and the same hash without the creator's id under `nameHash`, both computed by the -/// declaration as the platform computes them. Before it the domain declares no such -/// reference, and the preorder holds `saltedDomainHash`, the hash of the domain's -/// `preorderSalt` and `.dash`. A creator reference without a `findBy` -/// function is an error, never the older hash, which such a contract could not reveal. +/// function over the normalized label, `"."` and the normalized parent, which the preorder +/// holds under the property the function fills (`nameHash`), and chooses the first valid +/// preorder of the name, one holding under its `validWhen` property (`ownerNameHash`) the +/// hash of its owner's id and the same values: both computed by the declaration as the +/// platform computes them. Before it the domain declares no such reference, and the +/// preorder holds `saltedDomainHash`, the hash of the domain's `preorderSalt` and +/// `.dash`. A creator reference without a `findBy` function or a +/// `chooseFirst` is an error, never the older hash, which such a contract could not reveal. fn preorder_hash_properties( domain_document_type: DocumentTypeRef, owner_id: Identifier, @@ -104,15 +100,18 @@ fn preorder_hash_properties( Value::Bytes32(hash_double(salted_domain_buffer)), )])); }; - let (owner_name_hash_property, key) = reference + let lookup = reference .as_any_document_reference() .and_then(|declaration| declaration.lookup) - .and_then(|lookup| lookup.hash_key()) .ok_or_else(|| { - Error::Generic( - "the DPNS domain's creatorRefersTo declares no findBy function".to_string(), - ) + Error::Generic("the DPNS domain's creatorRefersTo declares no findBy".to_string()) })?; + let (name_hash_property, name_key) = lookup.hash_key().ok_or_else(|| { + Error::Generic("the DPNS domain's creatorRefersTo declares no findBy function".to_string()) + })?; + let choose_first = lookup.choose_first.as_ref().ok_or_else(|| { + Error::Generic("the DPNS domain's creatorRefersTo declares no chooseFirst".to_string()) + })?; let hash = |key: &LookupHashKey| { key.key_value(domain_document_type, None, owner_id, domain_properties) .map(|(hash, _)| Value::Bytes32(hash)) @@ -123,27 +122,21 @@ fn preorder_hash_properties( )) }) }; - let name_key = LookupHashKey { - function: key.function, - params: key - .params - .iter() - .filter(|param| !matches!(param, LookupKeyParam::Writer)) - .cloned() - .collect(), - }; Ok(BTreeMap::from([ - (owner_name_hash_property.to_string(), hash(key)?), - (NAME_HASH.to_string(), hash(&name_key)?), + (name_hash_property.to_string(), hash(name_key)?), + ( + choose_first.valid_when_property.clone(), + hash(&choose_first.valid_when)?, + ), ])) } -/// The query for the preorder `owner_id` holds with `owner_name_hash`, through the DPNS v3 +/// The query for the preorder `owner_id` holds with `name_hash`, through the DPNS v3 /// `ownerAndNameHash` index: at most one exists. fn pending_preorder_query( dpns_contract: Arc, owner_id: Identifier, - owner_name_hash: Value, + name_hash: Value, ) -> DocumentQuery { DocumentQuery { select: SelectProjection::documents(), @@ -156,9 +149,9 @@ fn pending_preorder_query( value: Value::Identifier(owner_id.to_buffer()), }, WhereClause { - field: OWNER_NAME_HASH.to_string(), + field: NAME_HASH.to_string(), operator: WhereOperator::Equal, - value: owner_name_hash, + value: name_hash, }, ], time_range_clauses: vec![], @@ -198,7 +191,7 @@ pub struct RegisterDpnsNameInput> { /// Result of a DPNS name registration #[derive(Debug)] pub struct RegisterDpnsNameResult { - /// The preorder document that was created. From protocol version 14 the domain + /// The preorder document that was created. From protocol version 15 the domain /// create deletes it, so it is no longer in state once the name is registered pub preorder_document: Document, /// The domain document that was created @@ -537,7 +530,7 @@ impl Sdk { } /// The preorder `owner_id` already holds of the name `preorder_document` commits to, - /// found by its owner and its `ownerNameHash` (the `ownerAndNameHash` index), when + /// found by its owner and its `nameHash` (the `ownerAndNameHash` index), when /// `dpns_contract` is DPNS v3 or later; `None` when there is none, and before v3, /// whose preorders hash a fresh salt and so never repeat. A failed query is an error. async fn pending_dpns_preorder( @@ -546,11 +539,10 @@ impl Sdk { owner_id: Identifier, preorder_document: &Document, ) -> Result, Error> { - let Some(owner_name_hash) = preorder_document.properties().get(OWNER_NAME_HASH).cloned() - else { + let Some(name_hash) = preorder_document.properties().get(NAME_HASH).cloned() else { return Ok(None); }; - let query = pending_preorder_query(Arc::new(dpns_contract), owner_id, owner_name_hash); + let query = pending_preorder_query(Arc::new(dpns_contract), owner_id, name_hash); // A proven absence comes back as entries holding `None` Ok(Document::fetch_many(self, query) .await? @@ -1052,9 +1044,9 @@ mod tests { } /// The pending preorder is looked up through `ownerAndNameHash`: the owner and the - /// preorder's own `ownerNameHash`, at most one + /// preorder's own `nameHash`, at most one #[test] - fn should_query_a_pending_preorder_by_its_owner_and_owner_name_hash() { + fn should_query_a_pending_preorder_by_its_owner_and_name_hash() { let owner = Identifier::new([1u8; 32]); let hash = Value::Bytes32([4u8; 32]); let query = pending_preorder_query(Arc::new(dpns_v3()), owner, hash.clone()); @@ -1068,7 +1060,7 @@ mod tests { value: Value::Identifier(owner.to_buffer()), }, WhereClause { - field: "ownerNameHash".to_string(), + field: "nameHash".to_string(), operator: WhereOperator::Equal, value: hash, }, @@ -1085,7 +1077,7 @@ mod tests { let query = pending_preorder_query( Arc::new(dpns_v3()), owner, - pending.properties()["ownerNameHash"].clone(), + pending.properties()["nameHash"].clone(), ); sdk.mock() .expect_fetch_many::( @@ -1110,9 +1102,9 @@ mod tests { assert_eq!( found .properties() - .get("ownerNameHash") + .get("nameHash") .and_then(|hash| hash.as_bytes_slice().ok()), - pending.properties()["ownerNameHash"].as_bytes_slice().ok() + pending.properties()["nameHash"].as_bytes_slice().ok() ); } @@ -1124,7 +1116,7 @@ mod tests { let query = pending_preorder_query( Arc::new(dpns_v3()), owner, - preorder.properties()["ownerNameHash"].clone(), + preorder.properties()["nameHash"].clone(), ); sdk.mock() .expect_fetch_many::( From 414676606394f225e98371b25ee82f4022283ec7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 13 Oct 2026 14:03:50 +0300 Subject: [PATCH 23/62] fix(drive-abci): keep DPNS v3 for its rebuild on an upgrade that skips versions A chain at protocol version 12 moving straight to 15 runs the upgrade rungs for 13, 14 and 15 in one block, each with version 15's tables. The rung for 13 loaded DPNS by that table, so it stored v3 with a plain update, which kept the old preorders and their index, and the rung for 15 then skipped its rebuild of a type already stored as declared. The rung for 13 now loads DPNS as protocol version 14 does (v2) when the target's table holds a later one. A target of 13 or 14 still loads it by its own table, so no ladder any chain can have run changes. Adds the 12 to 15 ladder regression: a stored legacy preorder is removed and the preorder layout equals a chain born at 15. Co-Authored-By: Claude Opus 5.5 --- .../v0/mod.rs | 15 ++++++- .../batch/tests/document/dpns_v3.rs | 42 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index c41baa4ca1a..25e190ec03a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -11,6 +11,7 @@ use dpp::platform_value::Identifier; use dpp::serialization::PlatformDeserializableTrusted; use dpp::system_data_contracts::dpns_contract::v1::document_types::preorder; use dpp::system_data_contracts::load_system_data_contract; +use dpp::version::v14::PROTOCOL_VERSION_14; use dpp::version::PlatformVersion; use dpp::version::ProtocolVersion; use dpp::voting::vote_polls::VotePoll; @@ -692,7 +693,19 @@ impl Platform { platform_version, )?; - let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version)?; + // A ladder reaching past protocol version 14 in one block (12 straight to 15) gives + // this rung a table holding DPNS v3, which only `transition_to_version_15` installs, + // through its rebuild of the preorders: a plain update here would leave them under + // v3, and the rebuild would then skip a type already stored as declared. This rung + // installs v2 then, loaded as protocol version 14, the newest holding it, would. A + // target of 13 or 14 loads v2 by its own table, as before. + let dpns_platform_version = if platform_version.system_data_contracts.dpns > 2 { + PlatformVersion::get(PROTOCOL_VERSION_14)? + } else { + platform_version + }; + let dpns_contract = + load_system_data_contract(SystemDataContract::DPNS, dpns_platform_version)?; self.drive.apply_contract( &dpns_contract, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index dd8a008d65b..76798612f32 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -397,6 +397,13 @@ mod dpns_v3_tests { /// Runs the protocol version 15 activation at block `height` on a /// chain at protocol version 14, as the first block of version 15 does. fn activate_protocol_version_15(&mut self, height: u64) { + self.activate_protocol_version_15_from(14, height); + } + + /// Runs the protocol version 15 activation at block `height` on a chain + /// at `previous`, as the first block of version 15 does: every rung of + /// the upgrade ladder above `previous`, each with version 15's tables. + fn activate_protocol_version_15_from(&mut self, previous: u32, height: u64) { let platform_version_15 = PlatformVersion::get(15).expect("expected platform version 15"); let platform_state = self.platform.state.load(); @@ -406,7 +413,7 @@ mod dpns_v3_tests { &platform_state, &block(height), &transaction, - 14, + previous, platform_version_15, ) .expect("expected the transition to protocol version 15 to succeed"); @@ -1158,6 +1165,39 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&preorder).is_none()); } + /// A chain at protocol version 12 voting straight for 15: the ladder runs the rungs for + /// 13, 14 and 15 in one block, each with version 15's tables. The rung for 13 re-stores + /// DPNS as protocol version 14 still declares it, v2, so the rung for 15 finds the + /// `preorder` type stored as v1 and v2 declare it and rebuilds it: the preorders made + /// before are gone and the layout is a chain born at 15's. + #[tokio::test] + async fn should_rebuild_the_preorders_on_a_ladder_from_protocol_version_12_to_15() { + let mut fixture = DpnsFixture::new(12); + let salt: [u8; 32] = fixture.rng.gen(); + let pending = fixture + .preorder(Who::Alice, salt, "quantum8", "dash", PREORDER_HEIGHT) + .await; + assert!(fixture.stored_preorder(&pending).is_some()); + + fixture.activate_protocol_version_15_from(12, 20); + + assert!(fixture + .dpns() + .document_type_for_name("preorder") + .expect("expected the preorder document type") + .required_fields() + .contains("$createdAtBlockHeight")); + assert!(fixture.stored_preorder(&pending).is_none()); + let born_at_15 = TestPlatformBuilder::new() + .with_initial_protocol_version(15) + .build_with_mock_rpc() + .set_genesis_state(); + assert_eq!( + preorder_type_layout(&fixture.platform, fixture.platform_version), + preorder_type_layout(&born_at_15, fixture.platform_version) + ); + } + /// A chain born at protocol version 15 stores DPNS v3 and still inserts /// the `dash` top-level domain, which `allowSubdomains` and whose owner is /// the DPNS contract owner. From e46c13403379fef54004cb509010e1f92d95bf49 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 09:31:47 +0300 Subject: [PATCH 24/62] feat(platform)!: hold a DPNS name to the preorders a reveal reads, with byte-array countOf keys (PV15) chooseFirst reads at most max_choose_first_candidates (1,000) preorders of a name older than the revealer's own, and a create stands when none of them is valid. 1,000 junk copies of a name's hash older than a valid preorder could therefore hide it from a later revealer. DPNS v3's preorder now declares a countable `nameHash` index and the rule `atMostAThousandPerName`, so a name never holds more preorders than a reveal reads (expired ones not yet cleaned up included). A preorder past it is refused (10422). The chooseFirst scanner is unchanged. `nameHash` is a 32-byte array, which a countOf key could not be at protocol version 14. CONTRACT_VERSIONS_V7 (protocol version 15) selects parse_property_constraints 1: a countOf or sumOf filter key may be a byte array property bound to a byte array property of the document, in propertyConstraints and deleteConstraints. validate_property_constraint_aggregates dispatches on the same slot; version 0 keeps its kinds and messages. Fees: a PV15 preorder 5,495,080 processing / 11,558,570 storage (was 4,910,640 / 11,558,153), the domain create 3,654,380 processing (was 3,404,300). The check_tx contract update pin that copies the preorder type moves to 29,003,303,730 (the new index's 0.01 Dash registration fee). Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 4 +- .../src/contract-keywords/refers-to-lookup.md | 1 + book/src/data-model/documents.md | 4 +- .../schema/v3/dpns-contract-documents.json | 24 ++ .../v1/mod.rs | 13 +- .../class_methods/try_from_schema/mod.rs | 232 +++++++++++++++- .../v3/byte_array_aggregate_key_tests.rs | 249 ++++++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 + .../document_type/property_constraints/mod.rs | 71 ++++- packages/rs-dpp/src/system_data_contracts.rs | 55 ++++ .../src/execution/check_tx/v0/mod.rs | 7 +- .../batch/tests/document/dpns_v3.rs | 131 ++++++++- .../dpp_versions/dpp_contract_versions/mod.rs | 5 +- .../dpp_versions/dpp_contract_versions/v7.rs | 20 ++ .../rs-platform-version/src/version/v15.rs | 20 +- 15 files changed, 799 insertions(+), 39 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/byte_array_aggregate_key_tests.rs create mode 100644 packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index a2f7d57de99..db370688387 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -274,7 +274,7 @@ A write that changes no property is not judged against a rule that compares prop | `{ "sumOf": ["pledge", "amount"] }` | The total `amount` over every `pledge` | `documentsSummable: "amount"` | | `{ "sumOf": ["pledge", "amount", { "campaignId": "campaignRef" }] }` | The total over those matching the filter | An index with `summable: "amount"` whose properties are exactly the filter's keys | -A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties (`"campaignRef"`), `$ownerId`, `$id` (its own id, for an identifier key), an integer, or a `{ "const": ... }` string or base58 identifier. The counted type may be the rule's own. `$id` counts the documents pointing at this one: `{ "countOf": ["vote", { "pollId": "$id" }] }` is how many votes name the poll being written. +A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties (`"campaignRef"`), `$ownerId`, `$id` (its own id, for an identifier key), an integer, or a `{ "const": ... }` string or base58 identifier. From protocol version 15 a key may also be a byte array property, matched by a byte array property of the document: `{ "countOf": ["preorder", { "nameHash": "nameHash" }] }` counts the preorders holding the same hash as the one being written. The counted type may be the rule's own. `$id` counts the documents pointing at this one: `{ "countOf": ["vote", { "pollId": "$id" }] }` is how many votes name the poll being written. ```json "propertyConstraints": { @@ -394,7 +394,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order, and no `ifThen` or `ifThenElse` holds two alike conditions; - no condition or operand nests more than 64 levels deep; - once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range, integer-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; -- every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind (`$ownerId` and `$id` are identifiers); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; +- every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, or from protocol version 15 a byte array property, and its value is of the same kind (`$ownerId` and `$id` are identifiers, and a byte array key takes a byte array property, never a constant); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; - every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total; - a `referenced` reads through a property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, and not beside what a write checking no reference changes: what a transfer or purchase of the type changes (`$ownerId`, `$transferredAt` and its heights or `$revision`, of either version, or a total depending on the owner), what a price update of the type changes (`$updatedAt` and its heights or `$revision`, of either version), on a type keeping fields for its moderators one of those fields or `$revision`, of either version, or any total, and, on a type whose moderators' deletions keep records, any total; - once every document type of the contract is parsed, what a rule reads of a referenced type of the same contract is a field that type gives it: one it has, stored, of the kind the rule reads, a time or height it records, a `$creatorId` it records, a constant its `enum` lists, and through a `moderatedDocument` reference a field its removal record keeps. A referenced type of another contract is checked at registration against the stored contract, refused with `ReferencedDocumentRuleReadInvalidError` (40148). diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index 9abcfcc5281..affbe5914ed 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -263,6 +263,7 @@ LIMIT 1000 -- max_choose_f - **Another identity's.** `findBy` finds the found preorder through the unique index over `$ownerId` and `nameHash`, which holds one preorder of a name per identity, and the writer's is the one found, so every competitor read is another identity's. - **Consumed.** With `consume` (beside the `where` entry `"$ownerId": "$ownerId"`), a reveal deletes the preorder it found, which then precedes nobody, so a later reveal of the same name by another identity, a contested join, is judged as if it had never existed. - **The bound.** The competitors are read in one query, in `by` order and only as far as the found preorder's value of the first ordered property, at most `max_choose_first_candidates` of `SystemLimits` (1,000 at protocol version 14). When none of them refuses, the create stands: refusing past the bound would let anyone block a name with copies. Each identity holds one preorder of a name, so pushing an older valid preorder past the bound takes 1,000 other identities, each paying for a preorder of the name ordered before the hidden one. Ordered by `$createdAtBlockHeight` ascending, as here, those land no later than the hidden one's block, while that one is still pending: a copy made once it is in state comes after it. Another `by` loses that timing: ordered newest first, copies posted after a valid preorder lands come before it and can fill the bound, and a property its creator picks can place a later copy ahead of it. Copies and expired preorders make a create cost more, never refuse it. +- **Holding a group to the bound.** A contract can keep every group within the bound, so a reveal always reads all of the preorders before its own: a `countOf` rule on the referenced type keyed by `among` (a byte array key, from protocol version 15), counted through a countable index over `among` alone. DPNS v3 declares `atMostAThousandPerName`, `{ "lessThanOrEqual": [{ "countOf": ["preorder", { "nameHash": "nameHash" }] }, 1000] }`, the bound itself. The count holds every stored preorder of the name, expired ones the cleanup has not removed included, so no valid preorder is ever left unread. A preorder past a thousand is refused instead (`DocumentPropertyConstraintViolatedError`, 10422) until one of the name is consumed, deleted or cleaned up: 1,000 copies of a name's hash, from 1,000 identities renewing them as they expire, keep a name from being preordered, where one valid preorder older than another identity's already wins a name easy to guess. - **Billing.** The `findBy` hash is billed as the double SHA-256 it is, the found preorder's `validWhen` hash too, the query as the document fetch it is, and `validWhen` computed for each competitor as a double SHA-256. The query and the competitors' hashes together bill at most the scan's budget: `choose_first_credits_per_candidate` of the fee schedule (100,000 credits at protocol version 14) times the candidate limit, 100,000,000 credits (0.001 DASH). Check tx does not validate a reveal's state, so it holds the signer to that whole budget, and to the `findBy` and `validWhen` hashes priced by the reveal's own values (a transient param counts, though nothing stores it), on top of the reveal's estimated fee: a reveal admitted to the mempool can always pay for its `chooseFirst` check in a block, where it is charged only what the check costs. Like any fee check, this holds for the balance the signer had when admitted. - **What it does not hide.** The `findBy` hash carries no salt: every preorder of a name holds the same one, so a preorder of a name easy to guess discloses it, from the moment it is broadcast. Guessed from a preorder already in a block, the name comes too late: a commitment to it lands in a later block, after the original, which precedes it. Guessed from a preorder still pending, it does not: the guesser can commit to the name and outbid the original, landing in the same block (where neither counts and the name's unique index decides between the reveals) or in an earlier one, whose commitment then comes first. `chooseFirst` settles the race to reveal; for a name easy to guess, the race to commit stays open. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 85861e55816..1fc0babc1b3 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -907,7 +907,7 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - a byte: `{ "byteAt": [path, index] }`, the byte, 0 to 255, at a literal index from 0 to 65535 of a byte array property, below its `maxItems` when it declares one. A byte the array does not hold, or one of an array the document leaves out, reads as 0, so `{ "in": [{ "byteAt": ["corePaymentAddress", 0] }, [0, 1]] }` holds a DIP-33 address to the P2PKH or P2SH type and lets a document without one through; `present` or `count` tells a 0 byte from a byte that is not there; - a system time or height: `"$createdAt"`, `"$updatedAt"` and `"$transferredAt"`, block times in milliseconds, and each with `BlockHeight` or `CoreBlockHeight` appended, the Platform and Core block heights: those of the document's creation, of its last create, replace or price update, and of its last create, transfer or purchase. A rule may read one only on a type that records it by listing it in `required`, so every stored document holds it; it takes no `ifAbsent`, and an indexOnly type, whose deletes carry none, reads none. `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation, and `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after it ends; - a value of the document a reference points at: `{ "referenced": [reference, field] }`, the `field` (a property path of the referenced type, its `$id`, `$ownerId` or `$creatorId`, or a time or height it records) of the document the top-level property `reference` finds through its `refersTo`, read as a path is, standing wherever a path stands. The reference holds one document, found by id or by a `findBy` without a function; consensus judges a rule reading one once the references hold, from the documents their checks read (see below); -- a total read from state: `{ "countOf": [type] }` or `{ "countOf": [type, filter] }`, how many documents of a type of the same contract there are, or how many match the filter, and `{ "sumOf": [type, property] }` or `{ "sumOf": [type, property, filter] }`, the total of an integer property over them. A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties, `$ownerId`, `$id` (its id, so `{ "countOf": ["vote", { "pollId": "$id" }] }` counts the votes naming it), an integer or a `{ "const": ... }`. The total is the one a count or sum tree keeps, as it will be once the write is done (the document itself counted when the type is its own, at its new values, and moved to its new owner by a transfer or purchase), so `{ "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] }` on `listing` keeps every owner at ten listings or fewer. A whole-type total needs `documentsCountable` or `documentsSummable`, and a filtered one an index of the counted type whose properties are exactly the filter's keys, countable or summing the property. A rule is judged only when its own type is written, so a fact about another type can go stale after the write. +- a total read from state: `{ "countOf": [type] }` or `{ "countOf": [type, filter] }`, how many documents of a type of the same contract there are, or how many match the filter, and `{ "sumOf": [type, property] }` or `{ "sumOf": [type, property, filter] }`, the total of an integer property over them. A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties, `$ownerId`, `$id` (its id, so `{ "countOf": ["vote", { "pollId": "$id" }] }` counts the votes naming it), an integer or a `{ "const": ... }`. A key is an integer, string or identifier property, and from protocol version 15 (`parse_property_constraints` 1, `CONTRACT_VERSIONS_V7`) a byte array property too, bound to a byte array property of the document; protocol version 14 reads such a binding as an integer and refuses it. The total is the one a count or sum tree keeps, as it will be once the write is done (the document itself counted when the type is its own, at its new values, and moved to its new owner by a transfer or purchase), so `{ "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] }` on `listing` keeps every owner at ten listings or fewer. A whole-type total needs `documentsCountable` or `documentsSummable`, and a filtered one an index of the counted type whose properties are exactly the filter's keys, countable or summing the property. A rule is judged only when its own type is written, so a fact about another type can go stale after the write. A JSON number is always a value and a string always a path, so a property named `100` is not confused with the number, and the rule is a tree the meta-schema can check rather than a string with precedence rules to parse. Consensus holds nothing but this tree; an SDK may offer an infix spelling that compiles to it. @@ -915,7 +915,7 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every path `byteAt` reads or a `startsWith` or `endsWith` of byte arrays tests a byte array property (within its `maxItems`, when it declares one), every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present`, `absent` or `countPresent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice and no `countPresent` a path twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one, a `countPresent` one plus one per path), that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value), and that the rules of one type read at most `max_property_constraint_aggregates` (4) distinct totals. Once every document type of the contract is parsed, a registration also checks every `countOf` and `sumOf`: the type it counts is one of the contract's and not indexOnly, nor the declaring type when it has a contested index (a document a contest awards is stored without the rules judged), a tree of it keeps the total, the filter's keys and values are integers, strings or identifiers of the same kind, and every property a value reads is required. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every path `byteAt` reads or a `startsWith` or `endsWith` of byte arrays tests a byte array property (within its `maxItems`, when it declares one), every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present`, `absent` or `countPresent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice and no `countPresent` a path twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one, a `countPresent` one plus one per path), that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value), and that the rules of one type read at most `max_property_constraint_aggregates` (4) distinct totals. Once every document type of the contract is parsed, a registration also checks every `countOf` and `sumOf`: the type it counts is one of the contract's and not indexOnly, nor the declaring type when it has a contested index (a document a contest awards is stored without the rules judged), a tree of it keeps the total, the filter's keys and values are integers, strings or identifiers of the same kind (from protocol version 15 byte arrays too, a byte array key bound to a byte array property; `validate_property_constraint_aggregates`, versioned on `parse_property_constraints`), and every property a value reads is required. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check changes nothing stored. It reads state only for the `countOf` and `sumOf` totals, which the document batch transformer reads when it builds the action (`Drive::fetch_property_constraint_aggregate`, billed with the write) and hands the check in `DocumentSystemValues::aggregates`; the limits bound its cost. `$ownerId`, the system times and heights and `$revision` read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer, the block's time and heights and revision 1, on a replace the writer, the stored creation and transfer values, the block's as the update and the replace's revision. A read through `$old.` reads the stored version `DocumentSystemValues::stored` gives: on a create `StoredVersion::Absent`, where a property reads as left out, `$old.$ownerId` equals no identifier and a stored time, height or revision reads 0, and on any other write the document as stored before it. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time, a height, a revision, a total or a stored version it is not given is not judged. Consensus reads every total the rules it judges read (`DocumentSystemValues::aggregates` is `Some`), and gives the stored version to every rule reading it, so one missing there is an error in the code building the write, never a skipped rule. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height the write records unjudged, since the height is unknown until the block; a stored height read through `$old.` is judged, reading 0 on a create. Transfers and purchases change no property, only the owner, the transfer's time and heights and `$revision`, so only the rules reading those, of the document or through `$old.` of the version before the write, or a total depending on the owner, are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, which asks `PropertyConstraint::reads_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights and `$revision`, and are judged against the rules reading those, of either version, the same way. A moderator's change of fields is judged by `validate_document_properties` with `DocumentSystemValues::changed_fields`, against the rules reading a field it changes (directly or through `$old.`), `$revision`, or a total, which may have moved since the document was last written (`PropertyConstraint::reads_changed_fields`), and a moderator's restore against the rules reading a total (`SystemChange::Restore`), `$old.` reading the restored document itself. diff --git a/packages/dpns-contract/schema/v3/dpns-contract-documents.json b/packages/dpns-contract/schema/v3/dpns-contract-documents.json index 2531953f7e1..8c35931323e 100644 --- a/packages/dpns-contract/schema/v3/dpns-contract-documents.json +++ b/packages/dpns-contract/schema/v3/dpns-contract-documents.json @@ -222,6 +222,15 @@ "$createdAtBlockHeight": "asc" } ] + }, + { + "name": "nameHash", + "properties": [ + { + "nameHash": "asc" + } + ], + "countable": "countable" } ], "properties": { @@ -244,6 +253,21 @@ "ownerNameHash", "nameHash" ], + "propertyConstraints": { + "atMostAThousandPerName": { + "lessThanOrEqual": [ + { + "countOf": [ + "preorder", + { + "nameHash": "nameHash" + } + ] + }, + 1000 + ] + } + }, "additionalProperties": false } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index 105c315cc5e..fb078d4bcb1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -323,10 +323,15 @@ impl DocumentType { } // What a `countOf` or `sumOf` totals is another document type of the contract, so it - // is checked once all are parsed. Inert for every protocol version before 14: only - // the tables carrying `parse_property_constraints: Some(_)` parse a rule at all. - validate_property_constraint_aggregates(&contract_document_types, schema_defs) - .map_err(consensus_or_protocol_data_contract_error)?; + // is checked once all are parsed, by the version of `parse_property_constraints` that + // parsed the rules. Inert for every protocol version before 14: only the tables + // carrying `parse_property_constraints: Some(_)` parse a rule at all. + validate_property_constraint_aggregates( + &contract_document_types, + schema_defs, + platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; // What a rule reads of a document a reference points at, when that document's type // is another of this contract; the referenced types of other contracts are read at 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 24dee140bb2..4f30e594e21 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 @@ -10,7 +10,8 @@ use crate::data_contract::document_type::property::reference_where::{ WHERE_CONST, WHERE_CONTAINS, WHERE_CONTAINS_ALL, WHERE_IN, WHERE_NOT_EQUAL, }; use crate::data_contract::document_type::property_constraints::{ - either_version_path, parse_delete_constraints, parse_property_constraints, referenced_path, + either_version_path, parse_delete_constraints, parse_delete_constraints_with_byte_array_keys, + parse_property_constraints, parse_property_constraints_with_byte_array_keys, referenced_path, referenced_read, stored_version_path, whole_integer, AffixPosition, AggregateBinding, AggregateKind, AggregateRead, ElementKind, EqualityKind, PropertyConstraint, PropertyRead, SystemChange, SystemProperty, SystemRead, SystemValue, REFERENCED_DOCUMENT_PREFIX, @@ -3245,6 +3246,13 @@ pub(super) fn apply_property_constraints( full_validation, platform_version, ), + Some(1) => apply_property_constraints_v1( + document_type, + schema_defs, + document_type_name, + full_validation, + platform_version, + ), Some(version) => Err(DataContractError::Unsupported(format!( "parse_property_constraints version {version} is not supported" ))), @@ -3426,6 +3434,129 @@ fn apply_delete_constraints_v0( Ok(()) } +/// [`apply_property_constraints_v0`] with the grammar of `parse_property_constraints` 1 +/// (protocol version 15): a `countOf` or `sumOf` filter key of a `propertyConstraints` or +/// `deleteConstraints` rule may be bound to a byte array property of the document +/// ([`parse_property_constraints_with_byte_array_keys`]), a read of that byte array that +/// [`validate_property_constraint_reads`] checks names one, neither transient nor inside a +/// transient object. Whether the key it binds is a byte array of the counted type is checked +/// once every type of the contract is parsed ([`validate_property_constraint_aggregates`]). +/// Everything else is version 0's. +fn apply_property_constraints_v1( + document_type: &mut DocumentTypeV2, + schema_defs: Option<&BTreeMap>, + document_type_name: &str, + full_validation: bool, + platform_version: &PlatformVersion, +) -> Result<(), DataContractError> { + let property_kind = + |path: &str| property_equality_kind(&document_type.flattened_properties, path); + let constraints = parse_property_constraints_with_byte_array_keys( + &document_type.schema, + document_type_name, + &property_kind, + )?; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" propertyConstraints {message}" + )) + }; + + for (name, constraint) in &constraints { + validate_property_constraint_reads( + document_type, + schema_defs, + &format!("rule \"{name}\""), + constraint, + true, + true, + &structure_error, + )?; + } + + if full_validation { + validate_rule_limits(&constraints, &structure_error, platform_version)?; + } + + document_type.property_constraints = constraints; + apply_delete_constraints_v1( + document_type, + schema_defs, + document_type_name, + full_validation, + platform_version, + ) +} + +/// [`apply_delete_constraints_v0`] with the grammar of `parse_property_constraints` 1 +/// ([`parse_delete_constraints_with_byte_array_keys`]); called by +/// [`apply_property_constraints_v1`]. +fn apply_delete_constraints_v1( + document_type: &mut DocumentTypeV2, + schema_defs: Option<&BTreeMap>, + document_type_name: &str, + full_validation: bool, + platform_version: &PlatformVersion, +) -> Result<(), DataContractError> { + let property_kind = + |path: &str| property_equality_kind(&document_type.flattened_properties, path); + let constraints = parse_delete_constraints_with_byte_array_keys( + &document_type.schema, + document_type_name, + &property_kind, + )?; + if constraints.is_empty() { + return Ok(()); + } + let keyword = property_names::DELETE_CONSTRAINTS; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" {keyword} {message}" + )) + }; + if document_type.documents_deleted_only_when_consumed { + return Err(structure_error( + "gate the delete of a document by its owner, but the type sets `canBeDeleted: \ + \"onlyWhenConsumed\"`: its owner never deletes one, and a consume deletes it \ + without the rules" + .to_string(), + )); + } + if !document_type.documents_can_be_deleted { + return Err(structure_error( + "gate the delete of a document by its owner, but the type sets `canBeDeleted: \ + false`, so there is no delete to gate" + .to_string(), + )); + } + if document_type.index_only { + return Err(structure_error( + "gate the delete of a stored document, but the type is indexOnly: its delete \ + carries the row's values, judged by the type's propertyConstraints as a create's" + .to_string(), + )); + } + + for (name, constraint) in &constraints { + validate_property_constraint_reads( + document_type, + schema_defs, + &format!("rule \"{name}\""), + constraint, + false, + false, + &structure_error, + )?; + } + + if full_validation { + validate_rule_limits(&constraints, &structure_error, platform_version)?; + } + + document_type.delete_constraints = constraints; + Ok(()) +} + /// How a condition compares the property at the dotted `path` of /// `flattened_properties` in an `equal`, `notEqual` or `in`: as a string or as /// an identifier, `None` as an integer (or not at all). A typed array compares @@ -4750,9 +4881,21 @@ enum AggregateKeyKind { Integer, Text, Identifier, + /// A byte array, from `parse_property_constraints` 1 (protocol version 15) only + /// ([`Self::of_admitting_byte_arrays`]). + Bytes, } impl AggregateKeyKind { + /// How a property of `property_type` compares as a key or a bound value under + /// `parse_property_constraints` 1: as [`Self::of`] says, and a byte array as one. + fn of_admitting_byte_arrays(property_type: &DocumentPropertyType) -> Option { + match property_type { + DocumentPropertyType::ByteArray(_) => Some(AggregateKeyKind::Bytes), + property_type => Self::of(property_type), + } + } + /// How a property of `property_type` compares as a key or a bound value: /// `None` for one that cannot (a boolean, a byte array, an object, ...). fn of(property_type: &DocumentPropertyType) -> Option { @@ -4777,6 +4920,7 @@ impl AggregateKeyKind { AggregateKeyKind::Integer => "an integer", AggregateKeyKind::Text => "a string", AggregateKeyKind::Identifier => "an identifier", + AggregateKeyKind::Bytes => "a byte array", } } } @@ -4794,9 +4938,70 @@ impl AggregateKeyKind { /// count or sum tree does not keep. Registration only: the index and property /// settings it relies on do not change on a contract update. `schema_defs`, /// the contract's `$defs`, resolves a filter key declared through a `$ref`. +/// +/// Versioned on `parse_property_constraints`, the version that parsed the rules: +/// 1 (protocol version 15) also admits a byte array key bound to a byte array +/// property ([`AggregateKeyKind::of_admitting_byte_arrays`]). Before 14 (`None`) +/// no rule parses, so version 0 finds nothing to check. pub(in crate::data_contract::document_type::class_methods) fn validate_property_constraint_aggregates( document_types: &BTreeMap, schema_defs: Option<&BTreeMap>, + platform_version: &PlatformVersion, +) -> Result<(), DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_property_constraints + { + None | Some(0) => validate_property_constraint_aggregates_with( + document_types, + schema_defs, + &AGGREGATE_KEY_GRAMMAR_V0, + ), + Some(1) => validate_property_constraint_aggregates_with( + document_types, + schema_defs, + &AGGREGATE_KEY_GRAMMAR_V1, + ), + Some(version) => Err(DataContractError::Unsupported(format!( + "parse_property_constraints version {version} is not supported" + ))), + } +} + +/// The keys a `countOf` or `sumOf` filter may name, and the values it may bind them to, under +/// one version of `parse_property_constraints`: `kind_of` says how a property compares as +/// either, and `keys` and `values` say in a refusal what they may be. +struct AggregateKeyGrammar { + kind_of: fn(&DocumentPropertyType) -> Option, + keys: &'static str, + values: &'static str, +} + +/// `parse_property_constraints` 0: integer, string and identifier keys. +const AGGREGATE_KEY_GRAMMAR_V0: AggregateKeyGrammar = AggregateKeyGrammar { + kind_of: AggregateKeyKind::of, + keys: "a key is an integer, string or identifier property, or $ownerId", + values: "a value matched is an integer, string or identifier property, $ownerId, $id, an \ + integer or a { \"const\": ... }", +}; + +/// `parse_property_constraints` 1 (protocol version 15): byte array keys too, each bound to a +/// byte array property of the document. +const AGGREGATE_KEY_GRAMMAR_V1: AggregateKeyGrammar = AggregateKeyGrammar { + kind_of: AggregateKeyKind::of_admitting_byte_arrays, + keys: "a key is an integer, string, identifier or byte array property, or $ownerId", + values: "a value matched is an integer, string, identifier or byte array property, \ + $ownerId, $id, an integer or a { \"const\": ... }", +}; + +/// [`validate_property_constraint_aggregates`] with the keys and values `grammar` admits. +fn validate_property_constraint_aggregates_with( + document_types: &BTreeMap, + schema_defs: Option<&BTreeMap>, + grammar: &AggregateKeyGrammar, ) -> Result<(), DataContractError> { for (type_name, document_type) in document_types { let declaring = document_type.as_ref(); @@ -4882,11 +5087,11 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ \"{counted_name}\" (a nested one is named by its dotted path)" ))); }; - let Some(kind) = AggregateKeyKind::of(&property.property_type) else { + let Some(kind) = (grammar.kind_of)(&property.property_type) else { return Err(error(format!( - "{totals} by \"{key}\", which has type {}: a key is an integer, \ - string or identifier property, or $ownerId", - property.property_type.name() + "{totals} by \"{key}\", which has type {}: {}", + property.property_type.name(), + grammar.keys ))); }; kind @@ -4908,6 +5113,14 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ the integer bare" ))); } + // Only `parse_property_constraints` 1 admits a byte array key + AggregateKeyKind::Bytes => { + return Err(error(format!( + "{totals} with \"{key}\" at the constant \ + \"{constant}\", but \"{key}\" is a byte array, \ + matched by a byte array property of the document" + ))); + } AggregateKeyKind::Identifier => { if Identifier::from_string(constant, Encoding::Base58).is_err() { @@ -4940,15 +5153,14 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ .flattened_properties() .get(path) .map(|property| &property.property_type); - let Some(kind) = property_type.and_then(AggregateKeyKind::of) else { + let Some(kind) = property_type.and_then(grammar.kind_of) else { return Err(error(format!( "{totals} with \"{key}\" at \"{path}\", which has type {}: \ - a value matched is an integer, string or identifier \ - property, $ownerId, $id, an integer or a \ - {{ \"const\": ... }}", + {}", property_type.map_or("none".to_string(), |property_type| { property_type.name() - }) + }), + grammar.values ))); }; (format!("\"{path}\""), kind) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/byte_array_aggregate_key_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/byte_array_aggregate_key_tests.rs new file mode 100644 index 00000000000..84749a1cd7e --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/byte_array_aggregate_key_tests.rs @@ -0,0 +1,249 @@ +//! `countOf` and `sumOf` matching their documents by a byte array key, bound to a byte array +//! property of the document written (`parse_property_constraints` 1, protocol version 15). +//! Protocol version 14 parses the same rule as reading the byte array as an integer, and +//! refuses it. + +use super::immutable_tests::expect_structure_error; +use super::*; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::property_constraints::{ + AggregateBinding, AggregateKind, AggregateRead, EqualityKind, PropertyRead, +}; +use crate::data_contract::DataContract; +use platform_value::string_encoding::Encoding; +use serde_json::json; + +/// A contract of one type, `commitment`, holding a 32-byte `nameHash`, a 20-byte `shortHash` +/// and an identifier `ownerRef`, each kept counted by an index of its own, and declaring +/// `rules` under `keyword` (`propertyConstraints` or `deleteConstraints`), parsed under +/// `platform_version`. +fn commitments( + keyword: &str, + rules: serde_json::Value, + platform_version: &PlatformVersion, +) -> Result { + let mut commitment = json!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "nameHash": { "type": "bytes", "size": 32, "position": 0 }, + "shortHash": { "type": "bytes", "size": 20, "position": 1 }, + "ownerRef": { "type": "identifier", "position": 2 } + }, + "required": ["nameHash", "shortHash", "ownerRef"], + "indices": [ + { + "name": "byNameHash", + "properties": [{ "nameHash": "asc" }], + "countable": "countable" + }, + { + "name": "byOwnerRef", + "properties": [{ "ownerRef": "asc" }], + "countable": "countable" + } + ], + "additionalProperties": false + }); + commitment[keyword] = rules; + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { "commitment": commitment } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + platform_version, + ) +} + +/// At most two commitments of a name, counted by the byte array they hold. +fn at_most_two_per_name() -> serde_json::Value { + json!({ + "atMostTwoPerName": { + "lessThanOrEqual": [{ "countOf": ["commitment", { "nameHash": "nameHash" }] }, 2] + } + }) +} + +fn by_name_hash() -> AggregateRead { + AggregateRead { + kind: AggregateKind::Count, + document_type: "commitment".to_string(), + filter: [( + "nameHash".to_string(), + AggregateBinding::Property { + path: "nameHash".to_string(), + kind: Some(EqualityKind::Bytes), + }, + )] + .into(), + of_own_type: true, + } +} + +/// From protocol version 15 a `countOf` matches its documents by a byte array key bound to a +/// byte array property, and reads that property as the byte array it is; protocol version 14 +/// reads it as an integer, which a byte array is not. +#[test] +fn should_count_by_a_byte_array_key_from_protocol_version_15() { + let contract = commitments( + "propertyConstraints", + at_most_two_per_name(), + PlatformVersion::latest(), + ) + .expect("a byte array key bound to a byte array registers"); + let commitment = contract + .document_type_for_name("commitment") + .expect("commitment"); + let rule = &commitment.property_constraints()["atMostTwoPerName"]; + assert_eq!(rule.aggregate_reads(), [&by_name_hash()]); + assert_eq!( + rule.property_reads(), + [("nameHash", PropertyRead::Bytes { extent: 0 })] + ); + assert_eq!( + by_name_hash() + .answering_index(&commitment) + .map(|index| index.name.as_str()), + Some("byNameHash") + ); + + expect_structure_error( + commitments( + "propertyConstraints", + at_most_two_per_name(), + PlatformVersion::get(14).expect("protocol version 14"), + ), + "rule \"atMostTwoPerName\" reads \"nameHash\", which has type byteArray, not integer \ + or boolean", + ); +} + +/// A `deleteConstraints` rule parses with the same grammar, byte array keys included. +#[test] +fn should_count_by_a_byte_array_key_in_a_delete_rule() { + let contract = commitments( + "deleteConstraints", + json!({ + "notTheLastOfItsName": { + "greaterThan": [{ "countOf": ["commitment", { "nameHash": "nameHash" }] }, 0] + } + }), + PlatformVersion::latest(), + ) + .expect("a delete rule may count by a byte array key"); + let commitment = contract + .document_type_for_name("commitment") + .expect("commitment"); + assert_eq!( + commitment.delete_constraints()["notTheLastOfItsName"].aggregate_reads(), + [&by_name_hash()] + ); +} + +/// A byte array key is matched by a byte array property alone, and a byte array property +/// matches no key of another kind. +#[test] +fn should_refuse_a_byte_array_key_matched_by_another_kind() { + let refused = |filter: serde_json::Value, needle: &str| { + expect_structure_error( + commitments( + "propertyConstraints", + json!({ + "rule": { + "lessThanOrEqual": [{ "countOf": ["commitment", filter] }, 2] + } + }), + PlatformVersion::latest(), + ), + needle, + ); + }; + refused( + json!({ "nameHash": "$ownerId" }), + "counts \"commitment\" with \"nameHash\", a byte array, at $ownerId, an identifier", + ); + refused( + json!({ "nameHash": "$id" }), + "counts \"commitment\" with \"nameHash\", a byte array, at $id, an identifier", + ); + refused( + json!({ "nameHash": "ownerRef" }), + "counts \"commitment\" with \"nameHash\", a byte array, at \"ownerRef\", an identifier", + ); + refused( + json!({ "ownerRef": "nameHash" }), + "counts \"commitment\" with \"ownerRef\", an identifier, at \"nameHash\", a byte array", + ); + refused( + json!({ "nameHash": { "const": "00" } }), + "counts \"commitment\" with \"nameHash\" at the constant \"00\", but \"nameHash\" is a \ + byte array, matched by a byte array property of the document", + ); +} + +/// The total is read from the count tree of the byte array the document holds: the filter +/// takes that byte array as the key of the counted type holds it, and a document of the type +/// adds to the total only when it holds the same one. +#[test] +fn should_match_a_document_by_the_byte_array_it_holds() { + let platform_version = PlatformVersion::latest(); + let contract = commitments( + "propertyConstraints", + at_most_two_per_name(), + platform_version, + ) + .expect("the contract registers"); + let commitment = contract + .document_type_for_name("commitment") + .expect("commitment"); + let owner = Identifier::from([3; 32]); + let document = |name_hash: [u8; 32]| { + Value::from(std::collections::BTreeMap::from([ + ("nameHash".to_string(), Value::Bytes32(name_hash)), + ("shortHash".to_string(), Value::Bytes20([9; 20])), + ("ownerRef".to_string(), Value::Identifier([4; 32])), + ])) + }; + let read = by_name_hash(); + let values = read + .filter_values( + commitment, + Identifier::from([5; 32]), + &document([1; 32]), + owner, + platform_version, + ) + .expect("reads") + .expect("a byte array the key holds"); + assert_eq!(values, [("nameHash".to_string(), Value::Bytes32([1; 32]))]); + assert_eq!( + read.contribution( + commitment, + &values, + &document([1; 32]), + owner, + platform_version + ) + .expect("reads"), + 1 + ); + assert_eq!( + read.contribution( + commitment, + &values, + &document([2; 32]), + owner, + platform_version + ) + .expect("reads"), + 0 + ); +} 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 9b5c9cea7a9..c1e526839eb 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 @@ -1964,6 +1964,8 @@ impl DocumentType { } } +#[cfg(all(test, feature = "validation"))] +mod byte_array_aggregate_key_tests; #[cfg(all(test, feature = "validation"))] mod byte_array_reads_tests; #[cfg(all(test, feature = "validation"))] diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 636a705ce87..e982100fcff 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1039,7 +1039,8 @@ pub enum AggregateKind { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum AggregateBinding { /// The document's property at the dotted `path`; `kind` is how it - /// compares, `None` for an integer. + /// compares, `None` for an integer. `EqualityKind::Bytes`, a byte array, + /// only under `parse_property_constraints` 1 (protocol version 15). Property { path: String, kind: Option, @@ -1366,11 +1367,13 @@ impl ConstraintExpression { for binding in read.filter.values() { if let AggregateBinding::Property { path, kind } = binding { let read = match kind { - // Never bytes: the parse reads a byte array as an - // integer here, which the parsed type refuses - None | Some(EqualityKind::Bytes) => PropertyRead::Value, + None => PropertyRead::Value, Some(EqualityKind::Text) => PropertyRead::Text, Some(EqualityKind::Identifier) => PropertyRead::Identifier, + // Only from `parse_property_constraints` 1: before it the + // parse reads a byte array here as an integer, which the + // parsed type refuses + Some(EqualityKind::Bytes) => PropertyRead::Bytes { extent: 0 }, }; visit(path, read)?; } @@ -2937,10 +2940,14 @@ impl PropertyConstraint { /// What the parse of one document type's rules knows of the type: its name, /// which tells an aggregate of the type's own documents from one of another -/// type's, and `property_kind` ([`parse_property_constraints`]). +/// type's, and `property_kind` ([`parse_property_constraints`]); and what the +/// grammar it parses admits: `byte_array_aggregate_keys`, whether a byte array +/// property of the document binds a key of a `countOf` or `sumOf` filter +/// ([`parse_property_constraints_with_byte_array_keys`]). struct ParseContext<'a> { document_type_name: &'a str, property_kind: &'a dyn Fn(&str) -> Option, + byte_array_aggregate_keys: bool, } impl ParseContext<'_> { @@ -3002,6 +3009,27 @@ pub fn parse_property_constraints( property_names::PROPERTY_CONSTRAINTS, document_type_name, property_kind, + false, + ) +} + +/// [`parse_property_constraints`] with the grammar of `parse_property_constraints` +/// 1 (protocol version 15): a key of a `countOf` or `sumOf` filter may also be +/// bound to a byte array property of the document, `{ "nameHash": "nameHash" }`, +/// which the parse reads as the byte array it is rather than as an integer. +/// Whether the key it binds is a byte array of the counted type is checked once +/// every type of the contract is parsed. +pub fn parse_property_constraints_with_byte_array_keys( + schema: &Value, + document_type_name: &str, + property_kind: &dyn Fn(&str) -> Option, +) -> Result, DataContractError> { + parse_named_rules( + schema, + property_names::PROPERTY_CONSTRAINTS, + document_type_name, + property_kind, + true, ) } @@ -3023,16 +3051,37 @@ pub fn parse_delete_constraints( property_names::DELETE_CONSTRAINTS, document_type_name, property_kind, + false, + ) +} + +/// [`parse_delete_constraints`] with the grammar of `parse_property_constraints` 1 +/// (protocol version 15): a `countOf` or `sumOf` filter key may be bound to a byte +/// array property, as [`parse_property_constraints_with_byte_array_keys`] admits. +pub fn parse_delete_constraints_with_byte_array_keys( + schema: &Value, + document_type_name: &str, + property_kind: &dyn Fn(&str) -> Option, +) -> Result, DataContractError> { + parse_named_rules( + schema, + property_names::DELETE_CONSTRAINTS, + document_type_name, + property_kind, + true, ) } /// Reads the rules `keyword` of `schema` declares by name -/// ([`parse_property_constraints`]). +/// ([`parse_property_constraints`]), a byte array property binding a `countOf` +/// or `sumOf` filter key when `byte_array_aggregate_keys` is set +/// ([`parse_property_constraints_with_byte_array_keys`]). fn parse_named_rules( schema: &Value, keyword: &str, document_type_name: &str, property_kind: &dyn Fn(&str) -> Option, + byte_array_aggregate_keys: bool, ) -> Result, DataContractError> { let structure_error = |message: String| { DataContractError::InvalidContractStructure(format!( @@ -3061,6 +3110,7 @@ fn parse_named_rules( let context = ParseContext { document_type_name, property_kind, + byte_array_aggregate_keys, }; let mut constraints = BTreeMap::new(); for (name, rule) in rules { @@ -3096,6 +3146,7 @@ pub fn parse_property_constraint_condition( let context = ParseContext { document_type_name, property_kind, + byte_array_aggregate_keys: false, }; parse_condition(condition, &mut String::new(), 0, &context) } @@ -4300,10 +4351,12 @@ fn parse_aggregate( $ownerId and $id" )) } - // A byte array matches no key: it reads as an integer, which the - // parsed document type refuses + // Before `parse_property_constraints` 1 a byte array matches no key: it + // reads as an integer, which the parsed document type refuses Value::Text(path) => AggregateBinding::Property { - kind: (context.property_kind)(path).filter(|kind| *kind != EqualityKind::Bytes), + kind: (context.property_kind)(path).filter(|kind| { + context.byte_array_aggregate_keys || *kind != EqualityKind::Bytes + }), path: path.clone(), }, binding if is_number(binding) => { diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 289a894f330..c0a0f712f8c 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -1164,6 +1164,10 @@ mod dpns_v3_tests { use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; + use crate::data_contract::document_type::property_constraints::{ + AggregateBinding, AggregateKind, AggregateRead, ConstraintComparison, ConstraintExpression, + EqualityKind, PropertyConstraint, + }; use crate::data_contract::document_type::{ ChooseFirstOrder, DocumentReferenceKind, DocumentTypeRef, HashFunction, LookupChooseFirst, LookupHashKey, LookupKeyParam, LookupKeySource, @@ -1323,4 +1327,55 @@ mod dpns_v3_tests { ) ); } + + /// A name holds at most `max_choose_first_candidates` preorders, counted by its name hash + /// through the countable `nameHash` index: the competitors a reveal reads are then always + /// all of those before the revealer's own, so none can be pushed past the read + #[test] + fn should_hold_a_name_to_the_preorders_a_reveal_reads() { + let contract = dpns(); + let preorder = document_type(&contract, "preorder"); + let index = preorder + .indexes() + .get("nameHash") + .expect("the nameHash index"); + assert!(index.countable.is_countable()); + assert!(!index.unique); + + let cap = preorder + .property_constraints() + .get("atMostAThousandPerName") + .expect("the per-name preorder cap"); + let by_name_hash = AggregateRead { + kind: AggregateKind::Count, + document_type: "preorder".to_string(), + filter: [( + "nameHash".to_string(), + AggregateBinding::Property { + path: "nameHash".to_string(), + kind: Some(EqualityKind::Bytes), + }, + )] + .into(), + of_own_type: true, + }; + assert_eq!( + cap, + &PropertyConstraint::Compare { + comparison: ConstraintComparison::LessThanOrEqual, + left: ConstraintExpression::Aggregate(by_name_hash.clone()), + right: ConstraintExpression::Value(i128::from( + PlatformVersion::latest() + .system_limits + .max_choose_first_candidates + )), + } + ); + assert_eq!( + by_name_hash + .answering_index(&preorder) + .map(|index| index.name.as_str()), + Some("nameHash") + ); + } } diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 1725be65083..873b3bb3a4b 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -1967,9 +1967,10 @@ mod tests { // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed // contract bytes; the DPNS v3 preorder type the update copies in has its own - // (its per-owner index, its non-unique `nameHashAndHeight` index registered at - // 0.01 Dash, `$createdAt` and `$createdAtBlockHeight` required, a ttl) - 28003227100 + // (its per-owner index, its non-unique `nameHashAndHeight` index and its countable + // `nameHash` index each registered at 0.01 Dash, `$createdAt` and + // `$createdAtBlockHeight` required, a ttl, the `atMostAThousandPerName` rule) + 29003303730 ); let check_result = platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index 76798612f32..fcf943c447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -27,7 +27,7 @@ mod dpns_v3_tests { use dpp::consensus::state::data_trigger::DataTriggerError; use dpp::data_contract::DataContract; use dpp::data_contracts::SystemDataContract; - use dpp::document::Document; + use dpp::document::{Document, DocumentV0}; use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; use dpp::prelude::{Identifier, IdentityNonce}; @@ -41,6 +41,8 @@ mod dpns_v3_tests { use drive::drive::RootTree; use drive::grovedb::Element; use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; + use drive::util::object_size_info::DocumentInfo::DocumentRefInfo; + use drive::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; use simple_signer::signer::SimpleSigner; @@ -819,6 +821,124 @@ mod dpns_v3_tests { assert!(fixture.stored_preorder(&alice_preorder).is_none()); } + /// A name holds at most `max_choose_first_candidates` preorders (`atMostAThousandPerName`), + /// so the preorders a reveal reads are always all of those before its own, and no copy can + /// push an older valid preorder past the read. Here 999 copies of the name's hash, each + /// another owner's and none valid, come a block before Alice's valid preorder, the + /// thousandth. Bob, who learned the name, cannot preorder it after her (10422), and Alice's + /// reveal, which reads all 999 copies before her preorder, registers the name. + #[tokio::test] + async fn should_refuse_a_preorder_past_the_preorders_a_reveal_reads() { + let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); + let platform_version = fixture.platform_version; + let cap = usize::from(platform_version.system_limits.max_choose_first_candidates); + let salt: [u8; 32] = fixture.rng.gen(); + let name_hash = dpns_preorder_properties( + fixture.id(Who::Alice), + &salt, + "quantum7", + "dash", + platform_version, + ) + .into_iter() + .find_map(|(property, value)| (property == "nameHash").then_some(value)) + .expect("a DPNS v3 preorder holds the name's hash"); + + // The copies are stored directly: as transitions they would need 999 identities + let dpns = fixture.dpns(); + let preorder_type = dpns + .document_type_for_name("preorder") + .expect("expected the preorder type"); + let copied_in = block(PREORDER_HEIGHT - 1); + for copy in 0..cap - 1 { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(copy as u64).to_be_bytes()); + let document: Document = DocumentV0 { + id: Identifier::new(id), + owner_id: Identifier::new(fixture.rng.gen()), + properties: BTreeMap::from([ + ("nameHash".to_string(), name_hash.clone()), + ( + "ownerNameHash".to_string(), + Value::Bytes32(fixture.rng.gen()), + ), + ]), + created_at: Some(copied_in.time_ms), + created_at_block_height: Some(copied_in.height), + ..Default::default() + } + .into(); + fixture + .platform + .drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, None)), + owner_id: None, + }, + contract: &dpns, + document_type: preorder_type, + }, + false, + copied_in, + true, + None, + platform_version, + None, + ) + .expect("expected to store the copy"); + } + + let alice_preorder = fixture + .preorder(Who::Alice, salt, "quantum7", "dash", PREORDER_HEIGHT) + .await; + + let bob_hashes = dpns_preorder_properties( + fixture.id(Who::Bob), + &salt, + "quantum7", + "dash", + platform_version, + ); + let bob_values: Vec<(&str, Value)> = bob_hashes + .iter() + .map(|(property, value)| (property.as_str(), value.clone())) + .collect(); + let (_, result) = fixture + .create(Who::Bob, "preorder", &bob_values, &[], PREORDER_HEIGHT) + .await; + assert_matches!( + refusal(&result), + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(e)) + if e.constraint() == "atMostAThousandPerName" + ); + + let (_, result) = fixture + .domain( + Who::Alice, + salt, + "quantum7", + "dash", + &[], + &[], + REVEAL_HEIGHT, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!( + fixture + .name("quantum7") + .expect("expected the name") + .owner_id(), + fixture.id(Who::Alice) + ); + assert!(fixture.stored_preorder(&alice_preorder).is_none()); + } + #[tokio::test] async fn should_refuse_a_name_without_its_preorder() { let mut fixture = DpnsFixture::new(PlatformVersion::latest().protocol_version); @@ -1393,8 +1513,9 @@ mod dpns_v3_tests { /// What a client pays for a name: the preorder and the domain create at protocol /// version 14 (create trigger, preorder kept, stored for good) and at 15 (keywords, a - /// preorder living an hour and priced for that time, consumed by the create without a - /// refund). Printed for the record and pinned. + /// preorder living an hour and priced for that time, counted toward its name's thousand + /// through the `nameHash` count tree, which its create reads, consumed by the create + /// without a refund). Printed for the record and pinned. #[tokio::test] async fn should_charge_a_name_at_protocol_versions_14_and_15() { let mut measured = vec![]; @@ -1449,8 +1570,8 @@ mod dpns_v3_tests { vec![ (1_159_720, 31_158_000, 0), (1_846_280, 42_687_000, 0), - (4_910_640, 11_558_153, 0), - (3_404_300, 42_687_000, 0) + (5_495_080, 11_558_570, 0), + (3_654_380, 42_687_000, 0) ] ); } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 00a37d48d8f..1a45f4f4ccb 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -5,6 +5,7 @@ pub mod v3; pub mod v4; pub mod v5; pub mod v6; +pub mod v7; #[derive(Clone, Debug, Default)] pub struct DPPContractVersions { @@ -159,7 +160,9 @@ pub struct DocumentTypeSchemaVersions { /// comparisons between integer expressions over the document's /// properties) onto the document type, and checks the properties they /// read. `None` on versions that predate the keyword: they ignore it - /// entirely, exactly as they parsed before it existed. + /// entirely, exactly as they parsed before it existed. 0 matches the + /// documents a `countOf` or `sumOf` totals by integer, string or + /// identifier keys; 1 (protocol version 15) by byte array keys too. pub parse_property_constraints: OptionalFeatureVersion, /// Parses the `generatedFrom` property keyword (a string property whose /// value a built-in function generates from other properties of the same diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs new file mode 100644 index 00000000000..96993ca0a9f --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -0,0 +1,20 @@ +use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; +use crate::version::dpp_versions::dpp_contract_versions::{ + DPPContractVersions, DocumentTypeSchemaVersions, DocumentTypeVersions, +}; + +/// Introduced in protocol version 15. Differs from v6 in `parse_property_constraints`, 1 rather +/// than 0: a `countOf` or `sumOf` of a `propertyConstraints` or `deleteConstraints` rule may +/// match its documents by a byte array key, bound to a byte array property of the document +/// written (or deleted), as it matches them by an integer, string or identifier key. v6 remains +/// unchanged for `PROTOCOL_VERSION_14`, whose parse refuses such a key. +pub const CONTRACT_VERSIONS_V7: DPPContractVersions = DPPContractVersions { + document_type_versions: DocumentTypeVersions { + schema: DocumentTypeSchemaVersions { + parse_property_constraints: Some(1), // changed: countOf and sumOf match byte array keys + ..CONTRACT_VERSIONS_V6.document_type_versions.schema + }, + ..CONTRACT_VERSIONS_V6.document_type_versions + }, + ..CONTRACT_VERSIONS_V6 +}; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 873d40a8935..afca63d6a52 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -1,6 +1,6 @@ use crate::version::consensus_versions::ConsensusVersions; use crate::version::dpp_versions::dpp_asset_lock_versions::v1::DPP_ASSET_LOCK_VERSIONS_V1; -use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; +use crate::version::dpp_versions::dpp_contract_versions::v7::CONTRACT_VERSIONS_V7; use crate::version::dpp_versions::dpp_costs_versions::v1::DPP_COSTS_VERSIONS_V1; use crate::version::dpp_versions::dpp_document_versions::v4::DOCUMENT_VERSIONS_V4; use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS_V1; @@ -93,7 +93,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// name (`ownerAndNameHash` over `$ownerId` and `nameHash`, replacing /// `saltedHash`), is indexed by `nameHash` and creation height /// (`nameHashAndHeight`) and lives an hour (`ttl` 3600, so it refunds nothing -/// when consumed). The `domain` keeps `preorderSalt` declared, transient and +/// when consumed). A name holds at most 1,000 preorders, the most a +/// `chooseFirst` reads (`max_choose_first_candidates`): the preorder's +/// `atMostAThousandPerName` rule counts them through a countable `nameHash` +/// index (note 4), so a reveal always reads every preorder before its own and +/// no copy can hide an older valid one; a preorder past it is refused (10422). The `domain` keeps `preorderSalt` declared, transient and /// no longer required, so domains written under v1 and v2 decode. The rule /// `recordsIdentityIsOwner` (`$transferredAt` equal to `$createdAt` implies /// `records.identity` equal to `$ownerId`) holds a new name's identity record @@ -111,6 +115,16 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// the parent's subdomain rule. The data trigger bindings stay at 2: their /// `domain` Replace and Delete rejects are never reached, as /// `documentsMutable` and `canBeDeleted` false refuse both first (10404). +/// +/// 4. **`countOf` and `sumOf` match byte array keys**: `CONTRACT_VERSIONS_V7` +/// selects `parse_property_constraints` 1, whose grammar binds a filter key +/// to a byte array property of the document written (or deleted), `{ +/// "nameHash": "nameHash" }`, and reads that property as a byte array, and +/// whose registration check admits a byte array key of the counted type bound +/// to a byte array property (never a constant, `$ownerId` or `$id`). The count +/// or sum tree holds the key as `serialize_value_for_key` writes it, so +/// consensus reads the total as it reads any other. `parse_property_constraints` +/// 0 (protocol version 14) reads such a binding as an integer and refuses it. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, @@ -129,7 +143,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V4, - contract_versions: CONTRACT_VERSIONS_V6, + contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V2, From b9eecc3b83df0014dd3a3611fa6c77553b3e3c1d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 09:31:47 +0300 Subject: [PATCH 25/62] fix(sdk): delete a pending DPNS preorder that expires before its reveal lands rs-sdk and js-dash-sdk revealed any stored pending preorder of the name. One past its hour (or within a minute of it, at the block time the query reports) is refused at the reveal (40120), and while the cleanup has not removed it, it also refuses a new preorder of the name as a duplicate (40105). Both SDKs now delete such a preorder, which its owner may do even once it has expired, and preorder the name anew. Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/names/register.spec.ts | 56 +++++++ .../Client/Platform/methods/names/register.ts | 39 ++++- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 143 +++++++++++++++--- 3 files changed, 218 insertions(+), 20 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index bb55eb8bb0c..08e8125ccde 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -356,6 +356,62 @@ describe('Platform', () => { expect(platformMock.documents.broadcast.callCount).to.equal(1); }); + // A pending preorder created at `createdAtMs`, found by a query reporting `nowMs` + const pendingPreorderAt = (createdAtMs: number, nowMs: number) => ({ + getCreatedAt: () => new Date(createdAtMs), + getMetadata: () => ({ getTimeMs: () => BigInt(nowMs) }), + }); + // DPNS v3, whose preorders live an hour + const dpnsV3Contract = () => ({ + getDocumentSchema: (type: string) => (type === 'preorder' + ? { ttl: 3600 } + : dpnsV3DomainSchema()), + }); + + it('should reveal a pending preorder a reveal reaches before it expires', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves(dpnsV3Contract()); + const createdAt = 1_700_000_000_000; + // Expiring in a minute and a millisecond, past the reveal's margin + platformMock.documents.get.resolves([ + pendingPreorderAt(createdAt, createdAt + 3_600_000 - 60_001), + ]); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + expect(platformMock.documents.create.callCount).to.equal(1); + expect(platformMock.documents.create.getCall(0).args[0]).to.equal('dpns.domain'); + expect(platformMock.documents.broadcast.callCount).to.equal(1); + }); + + it('should delete a pending preorder expiring before a reveal lands and preorder again', async () => { + const identityId = await generateRandomIdentifier(); + identityMock.getId.returns(identityId); + platformMock.contracts.get.resolves(dpnsV3Contract()); + const createdAt = 1_700_000_000_000; + // Expired an hour after its creation, but not yet removed by the cleanup + const expired = pendingPreorderAt(createdAt, createdAt + 3_600_000); + platformMock.documents.get.resolves([expired]); + platformMock.documents.create.onCall(0).resolves('preorder document'); + + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + + expect(platformMock.documents.broadcast.callCount).to.equal(3); + expect(platformMock.documents.broadcast.getCall(0).args[0]).to.deep.equal({ + delete: [expired], + }); + expect(platformMock.documents.create.getCall(0).args[0]).to.equal('dpns.preorder'); + expect(platformMock.documents.broadcast.getCall(1).args[0]).to.deep.equal({ + create: ['preorder document'], + }); + expect(platformMock.documents.create.getCall(1).args[0]).to.equal('dpns.domain'); + }); + it('should fail when the pending preorder query fails', async () => { const identityId = await generateRandomIdentifier(); identityMock.getId.returns(identityId); diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 24ecba72eee..d2a54624858 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -5,6 +5,28 @@ import convertToHomographSafeChars from '../../../../../utils/convertToHomograph const crypto = require('crypto'); const { hash } = require('@dashevo/wasm-dpp/lib/utils/hash'); +// How long before a pending preorder expires a registration stops revealing it: a domain +// create broadcast now lands in a later block, and a preorder expired at that block's time +// is not found. One minute covers the blocks a broadcast waits for. +const PREORDER_REVEAL_MARGIN_MS = 60_000; + +/** + * Whether a domain create broadcast now can still reveal `preorder`, of a type whose + * documents live `ttlSeconds`: the platform counts it expired from its $createdAt plus + * the time to live, at the time of the block the reveal lands in. Now is the time of the + * block the query that found it reports, or the local clock when it reports none. A + * preorder of a type with no time to live, or with no $createdAt, never expires. + */ +function isRevealable(preorder: any, ttlSeconds: number | undefined): boolean { + const createdAt = preorder.getCreatedAt?.(); + if (typeof ttlSeconds !== 'number' || !createdAt) { + return true; + } + const reportedTimeMs = preorder.getMetadata?.()?.getTimeMs(); + const nowMs = reportedTimeMs !== undefined ? Number(reportedTimeMs) : Date.now(); + return nowMs + PREORDER_REVEAL_MARGIN_MS < createdAt.getTime() + ttlSeconds * 1000; +} + /** * Register names to the platform * @@ -136,7 +158,22 @@ export async function register( }) : []; - if (!pendingPreorder) { + // One that expires before a reveal could land is not found by the reveal. While the + // cleanup of expired documents has not reached it, it still holds the owner's entry + // of the name, which refuses a new preorder as a duplicate: its owner deletes it + // and preorders the name anew. + const revealable = pendingPreorder + && isRevealable(pendingPreorder, dpnsContract?.getDocumentSchema('preorder')?.ttl); + if (pendingPreorder && !revealable) { + await this.documents.broadcast( + { + delete: [pendingPreorder], + }, + identity, + ); + } + + if (!revealable) { const preorderDocument = await this.documents.create( 'dpns.preorder', identity, diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index bcce79aa9eb..0d51bc439d8 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -8,6 +8,7 @@ pub use dash_platform_queries::dpns_usernames::{ pub use queries::DpnsUsername; use crate::platform::documents::document_query::DocumentQuery; +use crate::platform::documents::transitions::DocumentDeleteTransitionBuilder; use crate::platform::transition::put_document::PutDocument; use crate::platform::transition::put_settings::PutSettings; use crate::platform::{DataContract, Document, Fetch, FetchMany}; @@ -60,6 +61,36 @@ fn normalize_dpns_label(input: &str) -> String { /// read from the bundled DPNS contract, which the SDK builds only with that contract's feature. const NAME_HASH: &str = "nameHash"; +/// How long before a pending preorder expires the registration stops revealing it: a domain +/// create broadcast now lands in a later block, and a preorder expired at that block's time +/// is not found (40120). One minute covers the blocks a broadcast waits for. +const DPNS_PREORDER_REVEAL_MARGIN_MS: u64 = 60_000; + +/// A preorder of the name its owner already holds ([`Sdk::pending_dpns_preorder`]). +#[derive(Debug, Clone, PartialEq)] +enum PendingDpnsPreorder { + /// It lives long enough for a domain create broadcast now to reveal it. + Revealable(Document), + /// It has expired, or expires before a domain create broadcast now would land, but is + /// still stored: the cleanup of expired documents has not reached it. A reveal of it is + /// refused (40120), and so is another preorder of the name while it holds the owner's + /// `ownerAndNameHash` entry (40105), so its owner deletes it and preorders anew. + Expiring(Document), +} + +/// Whether `preorder`, of a type whose documents live `ttl_seconds`, can still be revealed by +/// a domain create broadcast at `now_ms`, the time of the newest block a proof reports: the +/// network counts it expired from `$createdAt` plus the time to live, so it must not reach that +/// within [`DPNS_PREORDER_REVEAL_MARGIN_MS`]. A preorder of a type with no time to live, or +/// with no `$createdAt`, never expires. +fn preorder_revealable(preorder: &Document, ttl_seconds: Option, now_ms: u64) -> bool { + let (Some(ttl_seconds), Some(created_at)) = (ttl_seconds, preorder.created_at()) else { + return true; + }; + let expires_at = created_at.saturating_add(u64::from(ttl_seconds).saturating_mul(1_000)); + now_ms.saturating_add(DPNS_PREORDER_REVEAL_MARGIN_MS) < expires_at +} + /// Hash a buffer twice using SHA256 (double SHA256) fn hash_double(data: Vec) -> [u8; 32] { use dpp::dashcore::hashes::{sha256d, Hash}; @@ -464,14 +495,34 @@ impl Sdk { // From DPNS v3 an identity holds one pending preorder of a name: one an earlier // registration left behind, whose domain create failed, is revealed again rather - // than preordered anew, which the platform would refuse as a duplicate + // than preordered anew, which the platform would refuse as a duplicate. One that + // expires before a reveal could land is deleted, then the name is preordered anew let pending_preorder = self - .pending_dpns_preorder(dpns_contract, identity_id, &preorder_document) + .pending_dpns_preorder(&dpns_contract, identity_id, &preorder_document) .await?; - let platform_preorder_document = if let Some(pending_preorder) = pending_preorder { + let platform_preorder_document = if let Some(PendingDpnsPreorder::Revealable( + pending_preorder, + )) = pending_preorder + { debug!(%identity_id, document_id = %pending_preorder.id(), stage = "preorder", "DPNS registration: revealing a pending preorder"); pending_preorder } else { + if let Some(PendingDpnsPreorder::Expiring(expiring)) = pending_preorder { + debug!(%identity_id, document_id = %expiring.id(), stage = "preorder", "DPNS registration: deleting an expiring preorder"); + self.document_delete( + DocumentDeleteTransitionBuilder::from_document( + Arc::new(dpns_contract), + "preorder".to_string(), + &expiring, + ), + &input.identity_public_key, + &input.signer, + ) + .await + .inspect_err(|error| { + warn!(%identity_id, stage = "preorder", %error, "DPNS registration: deleting the expiring preorder failed"); + })?; + } // Submit preorder document first debug!(%identity_id, stage = "preorder", "DPNS registration: submitting document"); let platform_preorder_document = preorder_document @@ -531,24 +582,36 @@ impl Sdk { /// The preorder `owner_id` already holds of the name `preorder_document` commits to, /// found by its owner and its `nameHash` (the `ownerAndNameHash` index), when - /// `dpns_contract` is DPNS v3 or later; `None` when there is none, and before v3, - /// whose preorders hash a fresh salt and so never repeat. A failed query is an error. + /// `dpns_contract` is DPNS v3 or later, revealable or expiring as the time of the block + /// the proof reports says ([`preorder_revealable`]); `None` when there is none, and + /// before v3, whose preorders hash a fresh salt and so never repeat. A failed query is an + /// error. async fn pending_dpns_preorder( &self, - dpns_contract: DataContract, + dpns_contract: &DataContract, owner_id: Identifier, preorder_document: &Document, - ) -> Result, Error> { + ) -> Result, Error> { let Some(name_hash) = preorder_document.properties().get(NAME_HASH).cloned() else { return Ok(None); }; - let query = pending_preorder_query(Arc::new(dpns_contract), owner_id, name_hash); + let ttl_seconds = dpns_contract + .document_type_for_name("preorder") + .map_err(|_| Error::Generic("DPNS preorder document type not found".to_string()))? + .documents_ttl_seconds(); + let query = pending_preorder_query(Arc::new(dpns_contract.clone()), owner_id, name_hash); + let (preorders, metadata) = Document::fetch_many_with_metadata(self, query, None).await?; // A proven absence comes back as entries holding `None` - Ok(Document::fetch_many(self, query) - .await? - .into_values() - .flatten() - .next()) + let Some(preorder) = preorders.into_values().flatten().next() else { + return Ok(None); + }; + Ok(Some( + if preorder_revealable(&preorder, ttl_seconds, metadata.time_ms) { + PendingDpnsPreorder::Revealable(preorder) + } else { + PendingDpnsPreorder::Expiring(preorder) + }, + )) } /// Check if a DPNS name is available @@ -1091,11 +1154,14 @@ mod tests { .await .expect("the expectation is set"); - let found = sdk - .pending_dpns_preorder(dpns_v3(), owner, &bob_v3_preorder(owner)) + // It records no `$createdAt`, so it never expires + let Some(PendingDpnsPreorder::Revealable(found)) = sdk + .pending_dpns_preorder(&dpns_v3(), owner, &bob_v3_preorder(owner)) .await .expect("the query answers") - .expect("the pending preorder is found"); + else { + panic!("the pending preorder is found, revealable"); + }; // The response is decoded as a proof is, so compare what identifies the preorder assert_eq!(found.id(), pending.id()); assert_eq!(found.owner_id(), owner); @@ -1127,7 +1193,7 @@ mod tests { .expect("the expectation is set"); assert_eq!( - sdk.pending_dpns_preorder(dpns_v3(), owner, &preorder) + sdk.pending_dpns_preorder(&dpns_v3(), owner, &preorder) .await .expect("the query answers"), None @@ -1141,11 +1207,50 @@ mod tests { // No expectation: the mock fails the request let sdk = mock_sdk(); assert!(sdk - .pending_dpns_preorder(dpns_v3(), owner, &bob_v3_preorder(owner)) + .pending_dpns_preorder(&dpns_v3(), owner, &bob_v3_preorder(owner)) .await .is_err()); } + /// A pending preorder is revealed only while a domain create broadcast now would land before + /// it expires: the network counts it expired from `$createdAt` plus the type's time to live, + /// and the registration keeps a minute's margin. Without a time to live, or a `$createdAt`, + /// it never expires. + #[test] + fn should_reveal_a_pending_preorder_only_while_a_reveal_lands_before_it_expires() { + let created_at = 1_700_000_000_000u64; + let mut preorder = bob_v3_preorder(Identifier::new([1u8; 32])); + let Document::V0(ref mut v0) = preorder; + v0.created_at = Some(created_at); + let ttl = dpns_v3() + .document_type_for_name("preorder") + .expect("the preorder type") + .documents_ttl_seconds(); + assert_eq!(ttl, Some(3600)); + let expires_at = created_at + 3_600_000; + + assert!(preorder_revealable(&preorder, ttl, created_at)); + assert!(preorder_revealable( + &preorder, + ttl, + expires_at - DPNS_PREORDER_REVEAL_MARGIN_MS - 1 + )); + assert!(!preorder_revealable( + &preorder, + ttl, + expires_at - DPNS_PREORDER_REVEAL_MARGIN_MS + )); + assert!(!preorder_revealable(&preorder, ttl, expires_at)); + assert!(!preorder_revealable(&preorder, ttl, expires_at + 1)); + + assert!(preorder_revealable(&preorder, None, expires_at + 1)); + assert!(preorder_revealable( + &bob_v3_preorder(Identifier::new([1u8; 32])), + ttl, + expires_at + 1 + )); + } + /// Before DPNS v3 a preorder hashes a fresh salt, so none repeats: nothing is queried #[tokio::test] async fn should_not_look_for_a_pending_salted_preorder() { @@ -1157,7 +1262,7 @@ mod tests { // No expectation: a query would fail the call let sdk = mock_sdk(); assert_eq!( - sdk.pending_dpns_preorder(dpns_v3(), owner, &salted) + sdk.pending_dpns_preorder(&dpns_v3(), owner, &salted) .await .expect("nothing is queried"), None From 054160195434a152868cc97cc1d63b73a962014f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 09:31:47 +0300 Subject: [PATCH 26/62] test(drive-abci): run the contest-fund upgrade test at the version it upgrades to The test upgrades a chain from protocol version 13 to the latest, which from 15 re-stores DPNS v3, and then ran it as version 14, whose create trigger queries the preorder by `saltedDomainHash`, an index v3 no longer has. The state now runs the version the upgrade went to. Co-Authored-By: Claude Opus 5.5 --- .../state_transitions/batch/tests/document/creation.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 157840f891c..5de1df8ea7e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -3981,7 +3981,7 @@ mod creation_tests { 13, platform_version, ) - .expect("expected the first block of protocol version 14"); + .expect("expected the first block of the latest protocol version"); platform .drive .grove @@ -3989,8 +3989,10 @@ mod creation_tests { .unwrap() .expect("expected to commit the transaction"); let mut upgraded_state = platform_state.as_ref().clone(); - upgraded_state.set_current_protocol_version_in_consensus(14); - upgraded_state.set_next_epoch_protocol_version(14); + // The state runs the version the upgrade went to: from protocol version 15 the + // upgrade re-stores DPNS v3, which the version 14 create trigger does not read + upgraded_state.set_current_protocol_version_in_consensus(platform_version.protocol_version); + upgraded_state.set_next_epoch_protocol_version(platform_version.protocol_version); platform.state.store(Arc::new(upgraded_state)); let platform_state = platform.state.load(); From 823e3af547df46d10a168e35a5ac53cfe80593d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 11:13:23 +0300 Subject: [PATCH 27/62] test(drive-abci): re-pin the nullifier batch guard roots for the DPNS v3 genesis The single-entry characterization pins GroveDB roots at the latest protocol version, whose genesis now stores DPNS v3 (its preorder type with the `nameHashAndHeight` and countable `nameHash` indexes). Only the root-carrying settled hashes move; every validation hash and fee is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../token/shielded/nullifier_batch_guard.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 2371cb0a380..a637f1d36c2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "5b430f0d21fc521ee60b2e336215e777e5ea29362a9a438a0ad4c6788ee7e8c8" + "2b58577a1cdb38e9da1f3fe0b16556f15cd11f851364bf8e404dfb4f14b4410a" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "dee201ee6bcacaad074abc0bb8397576aa051d21706096544b4ae00e767bce5a", + "4ba6e38cdbdda59c48644b8e0df3182a805bd87a7b3584906df8a84008c7df36", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "814e4be338931210577d98a57e5bf5b8ca345a39b9c34d2c33103ebd83713a59", + "1eb8b109ddb6a2d8896b113387132673e42f18f9abe560a607607593517ee3a3", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "7d5f448c6f0b34beadb2eb3fb78ba2eeff897ed2cf91bfcbb573f9f11d59c504", + "9f22a694033eb8c899df1fbad5c34bc97710ebad14aa2aff2dc495024bb6685b", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "7a3d8acac710f82d63b953a449c72714433e66138e6180349abd27ab926dbb0a", + "4a7e0f9b38b87219f276b6e54a4c0ace51245c23201057a0846be726f6854089", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "319ff415016cc4d239a6e661d05aa41d41f52048e16a546830281250f1002f9a", + "63be3fcbb363b244ab581de6cbaf38e50fd042bf8c276745ef62c2b5031abd53", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "8bcf55e33d62bc8cfa4993df2fc19754046f70be8af938f542011a1b2aea8bb6", + "637c59bb64c308336fb7de597fb34bb3137a39f9b45996b2ebbf238e7d7372ec", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "2d3dac540beb2247b12e2d3d9ee3d100dc96882be09cdaf580b7c4fa6a28afdf", + "0c0789e5dc329c11b67d202552957cd81cc4ca78feee6c019d1e6e519bcd7439", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "af4df8577290f277a295490c298e34c8175fd1ff426accd30405419988ca7a1e", + "9485f54bfbbe8c73408d48186132b9f1fc8e5fc37685712bda30bc6478d29cdc", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "36360aa84290589597eb3c15d7f64a28e519def668c3abeb3d7ec1e637c8d522", + "24fe37d9d5e2a81c90c7415007d6351f87aa253c051dcc75c473a4b69b33d71e", ), _ => unreachable!("enumerated characterization case"), }; From 7a92326d94b59fe0520c83ffa4409bac11db91b9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 11:13:23 +0300 Subject: [PATCH 28/62] fix(sdk): leave the DPNS records.identity rule to DPP in js-dash-sdk register.ts recognised DPNS v3's `recordsIdentityIsOwner` rule by name and re-implemented it as an owner comparison before the preorder. Validation lives once: the platform judges the rule when the domain is created (10422), and a refused domain leaves its preorder for the next registration to reveal. Also drops two type assertions the linter flags as unnecessary. Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/names/register.spec.ts | 22 +++++++++---------- .../Client/Platform/methods/names/register.ts | 12 ++-------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 08e8125ccde..650bce89e35 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -269,26 +269,24 @@ describe('Platform', () => { expect(apps.get('dpns').contract).to.equal(storedContract); }); - it('should refuse records.identity other than the registering identity under DPNS v3', async () => { + it('should leave the records.identity rule to the platform under DPNS v3', async () => { // Fixed ids: `crypto.randomBytes` is mocked to zeros here, so two random // identifiers would be the same identityMock.getId.returns(Identifier.from(Buffer.alloc(32, 1))); platformMock.contracts.get.resolves({ getDocumentSchema: dpnsV3DomainSchema, }); + const other = Identifier.from(Buffer.alloc(32, 2)); - let error; - try { - await register.call(platformMock, 'User.dash', { - identity: Identifier.from(Buffer.alloc(32, 2)), - }, identityMock); - } catch (e: any) { - error = e; - } + await register.call(platformMock, 'User.dash', { + identity: other, + }, identityMock); - expect(error.message).to.equal('records.identity must be the identity registering the name.'); - expect(platformMock.documents.create.called).to.equal(false); - expect(platformMock.documents.broadcast.called).to.equal(false); + // The contract's recordsIdentityIsOwner rule is judged by DPP when the domain + // is created, not copied here: the domain carries the record as given + expect(platformMock.documents.create.getCall(1).args[0]).to.equal('dpns.domain'); + expect(platformMock.documents.create.getCall(1).args[2].records.identity.equals(other)) + .to.equal(true); }); it('should refuse a creator reference without the chooseFirst hash before preordering', async () => { diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index d2a54624858..8f7297d3ec4 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -92,23 +92,15 @@ export async function register( entries ?? {}, ).find(([, source]: [string, any]) => Array.isArray(source?.params)) ?? []; const [hashProperty, hashFunction] = functionOf(creatorRefersTo?.findBy); - const hashParams: any[] | undefined = (hashFunction as any)?.params; + const hashParams: any[] | undefined = hashFunction?.params; const [validWhenProperty, validWhenFunction] = functionOf( creatorRefersTo?.constraints?.chooseFirst?.validWhen, ); - const validWhenParams: any[] | undefined = (validWhenFunction as any)?.params; + const validWhenParams: any[] | undefined = validWhenFunction?.params; if (Array.isArray(hashParams) && !Array.isArray(validWhenParams)) { throw new Error('The DPNS domain\'s creatorRefersTo declares no chooseFirst validWhen function.'); } - // From DPNS v3 a new name's records.identity must be its owner - // (recordsIdentityIsOwner): refuse another before the preorder is paid - if (domainSchema?.propertyConstraints?.recordsIdentityIsOwner - && records.identity - && !Identifier.from(records.identity).equals(identity.getId())) { - throw new Error('records.identity must be the identity registering the name.'); - } - const paramBytes = (param: any): Buffer => { if (typeof param === 'object' && param !== null) { return Buffer.from(param.const); From 97c7e85ae315fca206bdbcb02458dc3bf830fbe9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 11:13:23 +0300 Subject: [PATCH 29/62] test(sdk): cover the public DPNS registration up to its pending-preorder lookup register_dpns_name, from an SDK seeded at 13 with a DPNS v2 provider, learns the network's version, builds against the stored DPNS v3, hands it to the provider, and stops at the unanswered pending-preorder query, before any nonce fetch or signature. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 0d51bc439d8..bb78df13fbe 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -1066,6 +1066,99 @@ mod tests { assert!(declares_the_preorder_hash(&resolved)); } + /// A signer the tests never reach: each registration below stops before it signs. + #[derive(Debug)] + struct UnusedSigner; + + #[async_trait::async_trait] + impl Signer for UnusedSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(dpp::ProtocolError::Generic( + "not signing in this test".to_string(), + )) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(dpp::ProtocolError::Generic( + "not signing in this test".to_string(), + )) + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } + } + + /// The public registration, from an SDK seeded at protocol version 13 whose context + /// provider holds DPNS v2: it learns the network's version, builds against the DPNS v3 + /// the network stores, hands that contract to the provider, and looks for a pending + /// preorder of the name before it pays for a new one. That query is left unanswered, so + /// the registration stops with its error, before any nonce is fetched or anything signed. + #[tokio::test] + async fn should_look_for_a_pending_preorder_before_paying_for_one() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + + let provider = DpnsV2ContextProvider::default(); + let mut sdk = SdkBuilder::new_mock() + .with_initial_version(PlatformVersion::get(13).expect("protocol version 13")) + .with_context_provider(provider.clone()) + .build() + .expect("the mock SDK builds"); + expect_epoch_refresh(&mut sdk).await; + let stored = load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("the DPNS contract loads"); + sdk.mock() + .expect_fetch(stored.id(), Some(stored.clone())) + .await + .expect("the DPNS fetch is expected"); + + let owner_id = Identifier::new([1u8; 32]); + let error = sdk + .register_dpns_name(RegisterDpnsNameInput { + label: "Bob".to_string(), + identity: Identity::create_basic_identity(owner_id, PlatformVersion::latest()) + .expect("the identity builds"), + identity_public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![1; 33]), + disabled_at: None, + }), + signer: UnusedSigner, + preorder_callback: None, + contest_fund: None, + }) + .await + .expect_err("the unanswered pending-preorder query stops the registration"); + + // The request it stopped at is the documents query for the pending preorder, not the + // nonce fetch a new preorder would start with + let message = error.to_string(); + assert!( + message.contains("GetDocumentsRequest"), + "expected the pending-preorder query to fail, got {message}" + ); + assert_eq!(sdk.protocol_version_number(), LATEST_VERSION); + let resolved = provider + .get_data_contract(&stored.id(), PlatformVersion::latest()) + .expect("the provider answers") + .expect("the provider holds DPNS"); + assert!(declares_the_preorder_hash(&resolved)); + } + /// A DPNS v3 preorder of `owner` for `Bob.dash`, with the hashes the contract declares. fn bob_v3_preorder(owner: Identifier) -> Document { let properties = From b3db3ce7fc4199ed4ec4ef97dce7207dcbcc6804 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 11:58:14 +0300 Subject: [PATCH 30/62] fix(sdk): js-dash-sdk stops on an expiring pending DPNS preorder instead of deleting it js-dash-sdk reads documents and contracts without proofs, so a malicious node could steer the automatic delete of an expiring pending preorder at another of the caller's preorders. The delete could not succeed with honest answers either: a DPNS v3 preorder keeps no revision, which the document factory's delete refuses (RevisionAbsentError). The registration now stops before paying with a clear error, and the name can be preordered again once the expired preorder is cleaned up. rs-sdk, which verifies its proofs and builds the delete without a revision, keeps deleting and preordering anew. Co-Authored-By: Claude Opus 5.5 --- .../Platform/methods/names/register.spec.ts | 33 ++++++++++--------- .../Client/Platform/methods/names/register.ts | 25 +++++++------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts index 650bce89e35..59e3f98b238 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.spec.ts @@ -385,29 +385,30 @@ describe('Platform', () => { expect(platformMock.documents.broadcast.callCount).to.equal(1); }); - it('should delete a pending preorder expiring before a reveal lands and preorder again', async () => { + it('should stop before paying when a pending preorder expires before a reveal lands', async () => { const identityId = await generateRandomIdentifier(); identityMock.getId.returns(identityId); platformMock.contracts.get.resolves(dpnsV3Contract()); const createdAt = 1_700_000_000_000; // Expired an hour after its creation, but not yet removed by the cleanup - const expired = pendingPreorderAt(createdAt, createdAt + 3_600_000); - platformMock.documents.get.resolves([expired]); - platformMock.documents.create.onCall(0).resolves('preorder document'); + platformMock.documents.get.resolves([ + pendingPreorderAt(createdAt, createdAt + 3_600_000), + ]); - await register.call(platformMock, 'User.dash', { - identity: identityId, - }, identityMock); + let error; + try { + await register.call(platformMock, 'User.dash', { + identity: identityId, + }, identityMock); + } catch (e: any) { + error = e; + } - expect(platformMock.documents.broadcast.callCount).to.equal(3); - expect(platformMock.documents.broadcast.getCall(0).args[0]).to.deep.equal({ - delete: [expired], - }); - expect(platformMock.documents.create.getCall(0).args[0]).to.equal('dpns.preorder'); - expect(platformMock.documents.broadcast.getCall(1).args[0]).to.deep.equal({ - create: ['preorder document'], - }); - expect(platformMock.documents.create.getCall(1).args[0]).to.equal('dpns.domain'); + // Nothing is signed on an unproved answer: no delete, preorder or domain + expect(error.message).to.equal('The pending preorder of this name expires before its ' + + 'reveal could land. Retry once it has expired and the platform has removed it.'); + expect(platformMock.documents.create.called).to.equal(false); + expect(platformMock.documents.broadcast.called).to.equal(false); }); it('should fail when the pending preorder query fails', async () => { diff --git a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts index 8f7297d3ec4..6f380d00bd4 100644 --- a/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts +++ b/packages/js-dash-sdk/src/SDK/Client/Platform/methods/names/register.ts @@ -150,22 +150,19 @@ export async function register( }) : []; - // One that expires before a reveal could land is not found by the reveal. While the - // cleanup of expired documents has not reached it, it still holds the owner's entry - // of the name, which refuses a new preorder as a duplicate: its owner deletes it - // and preorders the name anew. - const revealable = pendingPreorder - && isRevealable(pendingPreorder, dpnsContract?.getDocumentSchema('preorder')?.ttl); - if (pendingPreorder && !revealable) { - await this.documents.broadcast( - { - delete: [pendingPreorder], - }, - identity, - ); + // One that expires before a reveal could land is not found by the reveal, and while + // the cleanup of expired documents has not reached it, it still holds the owner's + // entry of the name, which refuses a new preorder as a duplicate. This SDK reads + // documents and contracts without proofs, so it signs nothing destructive on what a + // node answered: it stops before paying, and the name can be preordered again once + // the cleanup has removed the expired preorder. + if (pendingPreorder + && !isRevealable(pendingPreorder, dpnsContract?.getDocumentSchema('preorder')?.ttl)) { + throw new Error('The pending preorder of this name expires before its reveal could land. ' + + 'Retry once it has expired and the platform has removed it.'); } - if (!revealable) { + if (!pendingPreorder) { const preorderDocument = await this.documents.create( 'dpns.preorder', identity, From 5e53930a025c09f8b481323e23fb354ba3325eb6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 12:26:26 +0300 Subject: [PATCH 31/62] test(sdk): judge a pending DPNS preorder at the time its response reports The expiry arithmetic had direct tests, but no test drove pending_dpns_preorder to Expiring: the mock reported a response time of 0, so a stored preorder could never look expired. The mock SDK can now set the block time its answers report (set_response_time_ms, 0 by default), and a lookup test checks both sides of the one-minute margin with the fetched ttl and the reported time. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/mock/sdk.rs | 12 +++++ .../rs-sdk/src/platform/dpns_usernames/mod.rs | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index 49d781b3a47..2fd5d041d9f 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -45,6 +45,9 @@ pub struct MockDashPlatformSdk { from_proof_expectations: BTreeMap>, dapi: Arc>, sdk: ArcSwapOption, + /// The block time the metadata of a response answered from an expectation reports + /// ([Self::set_response_time_ms]); 0 unless set. + response_time_ms: u64, } impl MockDashPlatformSdk { @@ -76,9 +79,17 @@ impl MockDashPlatformSdk { from_proof_expectations: Default::default(), dapi, sdk: ArcSwapOption::new(None), + response_time_ms: 0, } } + /// Sets the block time, in milliseconds, the metadata of every response answered from an + /// expectation reports, for code under test that reads the time a response was proved at. + pub fn set_response_time_ms(&mut self, time_ms: u64) -> &mut Self { + self.response_time_ms = time_ms; + self + } + pub(crate) fn set_sdk(&mut self, sdk: Sdk) { self.sdk.store(Some(Arc::new(sdk))); } @@ -582,6 +593,7 @@ impl MockDashPlatformSdk { Option::::mock_deserialize(self, d), ResponseMetadata { protocol_version: dpp::version::LATEST_VERSION, + time_ms: self.response_time_ms, ..Default::default() }, Proof::default(), diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index bb78df13fbe..ed0240fd598 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -1293,6 +1293,59 @@ mod tests { ); } + /// The pending preorder is judged at the block time its response reports, against the + /// fetched preorder type's time to live and the minute's margin: one created an hour and a + /// minute and a millisecond before a reveal broadcast then could reach is revealable, one + /// created a millisecond earlier is expiring, and either way the found document is the one + /// returned. + #[tokio::test] + async fn should_judge_a_pending_preorder_at_the_time_its_response_reports() { + let owner = Identifier::new([1u8; 32]); + let now = 1_700_000_000_000u64; + let ttl_ms = 3_600_000u64; + for (created_at, expiring) in [ + (now + DPNS_PREORDER_REVEAL_MARGIN_MS - ttl_ms + 1, false), + (now + DPNS_PREORDER_REVEAL_MARGIN_MS - ttl_ms, true), + ] { + let mut pending = bob_v3_preorder(owner); + let Document::V0(ref mut v0) = pending; + v0.created_at = Some(created_at); + let mut sdk = mock_sdk(); + let query = pending_preorder_query( + Arc::new(dpns_v3()), + owner, + pending.properties()["nameHash"].clone(), + ); + sdk.mock() + .set_response_time_ms(now) + .expect_fetch_many::( + query, + Some( + [(pending.id(), Some(pending.clone()))] + .into_iter() + .collect(), + ), + ) + .await + .expect("the expectation is set"); + + let found = sdk + .pending_dpns_preorder(&dpns_v3(), owner, &bob_v3_preorder(owner)) + .await + .expect("the query answers") + .expect("the pending preorder is found"); + let document = match (found, expiring) { + (PendingDpnsPreorder::Revealable(document), false) + | (PendingDpnsPreorder::Expiring(document), true) => document, + (found, _) => panic!( + "created at {created_at}, judged at {now}: expected expiring {expiring}, \ + got {found:?}" + ), + }; + assert_eq!(document.id(), pending.id()); + } + } + /// A query that fails is an error, never taken for no pending preorder #[tokio::test] async fn should_propagate_a_failed_pending_preorder_query() { From 082c561e1be9e71c8bde29906330ac2d28bd5d5f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 20:38:32 +0300 Subject: [PATCH 32/62] feat(platform)!: tradeable tokens (PV15) A token's marketplace rules gain the trade mode `Tradeable`, the opt-in of the token marketplace, where holders sell a token to each other through escrowed listings and offers. - Before protocol version 15 a contract whose token is tradeable, and a TokenConfigUpdate that makes a token tradeable, are refused unpaid as not active (StateTransitionNotActiveError, 10603): the refusal older software gives by failing to decode the mode. - A tradeable token must be transferable (NonTransferableTokenTradeableError, 10281): contract create and update refuse it unpaid, and config update state validation 2 (protocol version 15) checks the configuration an update proposes. - wasm-dpp2 exposes TokenTradeMode.Tradeable(). Co-Authored-By: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + .../src/data-model/non-transferable-tokens.md | 5 +- book/src/data-model/token-marketplace.md | 51 +++++ .../token_configuration/mod.rs | 164 +++++++++++++- .../token_marketplace_rules/mod.rs | 42 +++- .../token_marketplace_rules/v0/mod.rs | 16 ++ .../src/errors/consensus/basic/basic_error.rs | 27 ++- .../consensus/basic/data_contract/mod.rs | 2 + .../non_transferable_token_tradeable_error.rs | 57 +++++ packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../processor/traits/is_allowed.rs | 210 ++++++++++++++++-- .../mod.rs | 12 +- .../state_v2/mod.rs | 65 ++++++ .../batch/tests/token/non_transferable/mod.rs | 145 ++++++++++++ .../drive_abci_validation_versions/v11.rs | 4 + .../feature_initial_protocol_versions.rs | 5 + .../rs-platform-version/src/version/v15.rs | 11 + .../src/errors/consensus/consensus_error.rs | 5 +- .../src/tokens/configuration/trade_mode.rs | 7 + .../tests/unit/TokenConfiguration.spec.ts | 27 +++ .../tests/unit/TokensTransitions.spec.ts | 1 - 21 files changed, 830 insertions(+), 28 deletions(-) create mode 100644 book/src/data-model/token-marketplace.md create mode 100644 packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_tradeable_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index f0aa3d3b131..9bc4d4cf92c 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -64,6 +64,7 @@ - [Key Budgets and Expiry](data-model/key-limits.md) - [Token Shielded Pools](data-model/token-shielded-pools.md) - [Non-Transferable Tokens](data-model/non-transferable-tokens.md) +- [Token Marketplace](data-model/token-marketplace.md) # Contract Keywords diff --git a/book/src/data-model/non-transferable-tokens.md b/book/src/data-model/non-transferable-tokens.md index 4794e0d71a9..b902320dfde 100644 --- a/book/src/data-model/non-transferable-tokens.md +++ b/book/src/data-model/non-transferable-tokens.md @@ -46,9 +46,12 @@ contract update cannot change an existing token's configuration. | A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed. A document payment refuses it again (`TokenNotTransferableError`, 40726) from the configuration the contract already carries, so a contract that bypassed registration cannot pay with it either | | A document type of another contract charging it | `TokenNotTransferableError` (40726) | Contract create and update state validation, which already reads the token's contract to check the token exists. Such a cost always pays the contract owner, since another contract's token cannot be burned (10261) | | `hasShieldedPool: true` | `NonTransferableTokenShieldedPoolError` (10279) | Contract create and update, before anything is charged | +| `marketplaceRules.tradeMode: Tradeable` (protocol version 15) | `NonTransferableTokenTradeableError` (10281) | Contract create and update, before anything is charged, and the state validation of a `TokenConfigUpdate` that makes the token tradeable | A shielded pool is refused because a note spent inside the pool can be unshielded to any -identity, so shielding and unshielding would move the token between holders. +identity, so shielding and unshielding would move the token between holders. The +[token marketplace](token-marketplace.md) is refused because filling a listing or an offer moves +the token from the seller to the buyer. Every rule is checked when the contract is registered or when the transfer arrives, and the flag cannot change afterwards: a document type that could pay the owner in a non-transferable token diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md new file mode 100644 index 00000000000..7a1c871797f --- /dev/null +++ b/book/src/data-model/token-marketplace.md @@ -0,0 +1,51 @@ +# Token Marketplace + +From protocol version 15 a token can be tradeable: its holders can sell it to each other on +Platform at a price they set, and anyone can make an offer for it. Nobody has to trust the other +side. The tokens a seller lists and the credits a buyer offers are held in escrow until the order +is filled or cancelled, so a fill always delivers what the order promised. + +This is a resale market between holders. It is separate from direct purchase +(`TokenSetPriceForDirectPurchase` and `TokenDirectPurchase`), where the token's issuer sets a +price and each purchase mints new tokens and pays the contract owner. + +## Making a token tradeable + +A token opts in through its `marketplaceRules`: + +```json +"marketplaceRules": { + "$formatVersion": "0", + "tradeMode": "Tradeable", + "tradeModeChangeRules": { + "$formatVersion": "0", + "authorizedToMakeChange": { "$type": "contractOwner" }, + "adminActionTakers": { "$type": "noOne" }, + "changingAuthorizedActionTakersToNoOneAllowed": true, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + } +} +``` + +| `tradeMode` | Meaning | +|---|---| +| `NotTradeable` (the default) | Holders cannot list the token or make offers for it on Platform | +| `Tradeable` | Holders can list the token and make offers for it | + +The mode is set when the token is created, or later by a `TokenConfigUpdate` with the +`MarketplaceTradeMode` item, which `tradeModeChangeRules` governs like every other configuration +item. A token whose `tradeModeChangeRules` let no one make the change keeps the mode it was +created with. That includes every token created with the default rules, so a token that was not +created tradeable becomes tradeable only if its issuer kept the right to change it. + +## Rules + +| Rule | Refused with | Where | +|---|---|---| +| `Tradeable` before protocol version 15 | `StateTransitionNotActiveError` (10603), as `TokenTradeModeTradeable` on a contract and `TokenConfigUpdateMarketplaceTradeModeTradeable` on an update | Contract create and update, and the batch carrying the update, before anything is charged. Software older than protocol version 15 cannot decode the mode, so this is the refusal it gives too | +| `Tradeable` on a [non-transferable token](non-transferable-tokens.md) | `NonTransferableTokenTradeableError` (10281) | Contract create and update before anything is charged, and the state validation of a `TokenConfigUpdate`, which charges the signer | + +A non-transferable token cannot be tradeable because filling a listing or an offer moves the token +from the seller to the buyer, which is the transfer the token refuses. `transferable` is fixed +when the token is created, so the trade mode is the only half of the rule an update can change. diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs index 51e03195361..a2c80951c35 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs @@ -1,11 +1,14 @@ use crate::consensus::basic::data_contract::{ - NonTransferableTokenShieldedPoolError, TokenShieldedPoolIncompatibleRulesError, + NonTransferableTokenShieldedPoolError, NonTransferableTokenTradeableError, + TokenShieldedPoolIncompatibleRulesError, }; +use crate::consensus::basic::state_transition::StateTransitionNotActiveError; use crate::consensus::basic::unsupported_version_error::UnsupportedVersionError; use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1; +use crate::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Getters; use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use crate::data_contract::change_control_rules::ChangeControlRules; use crate::data_contract::errors::DataContractError; @@ -17,6 +20,7 @@ use crate::serialization::ValueConvertible; use crate::validation::SimpleConsensusValidationResult; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; +use platform_version::version::feature_initial_protocol_versions::TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION; use platform_version::version::PlatformVersion; use serde::{Deserialize, Serialize}; use std::borrow::Cow; @@ -58,6 +62,21 @@ impl TokenConfiguration { } } + /// Whether the configuration uses anything that needs the unpaid pre-activation gate of + /// contract create and update: a format version above 0 or the `Tradeable` trade mode. + /// Software that predates either cannot decode the configuration, and the frozen contract + /// basic structure generations of protocol versions 9 to 13 do not look at either, so a + /// configuration that uses one is checked by `validate_token_configurations` before anything + /// else. + pub fn needs_activation_check(&self) -> bool { + self.format_version() > 0 + || self + .as_cow_v0() + .marketplace_rules + .trade_mode() + .is_tradeable() + } + /// Checks the configuration's format version against the bounds the platform version /// admits (`dpp.contract_versions.token_versions.token_configuration_format`). /// @@ -131,6 +150,44 @@ impl TokenConfiguration { SimpleConsensusValidationResult::new() } + /// A tradeable token (`marketplaceRules.tradeMode` `Tradeable`) is admitted from protocol + /// version 15 (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`): below it the mode is refused as + /// not active, the refusal software that predates the marketplace gives by failing to decode + /// it. A tradeable token must also be transferable, since filling a listing or an offer moves + /// the token from one identity to another. Checked on contract create and update and on the + /// configuration a `TokenConfigUpdate` proposes; `transferable` is fixed at creation, so only + /// the trade mode can change. + pub fn validate_marketplace_rules( + &self, + token_contract_position: TokenContractPosition, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + if !self + .as_cow_v0() + .marketplace_rules + .trade_mode() + .is_tradeable() + { + return SimpleConsensusValidationResult::new(); + } + if platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { + return SimpleConsensusValidationResult::new_with_error( + StateTransitionNotActiveError::new( + "TokenTradeModeTradeable", + platform_version.protocol_version, + TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ); + } + if !self.is_transferable() { + return SimpleConsensusValidationResult::new_with_error( + NonTransferableTokenTradeableError::new(token_contract_position).into(), + ); + } + SimpleConsensusValidationResult::new() + } + /// The pool's outgoing notes threshold may not exceed /// `max_token_pool_notes_for_outgoing`: a threshold the pool never reaches would refuse /// every outflow and strand every shielded balance, irreversibly on a readonly contract. @@ -174,7 +231,8 @@ pub fn validate_minimum_pool_notes_for_outgoing_bound( /// Validates every token configuration of a contract for `platform_version`: the format /// version must be admitted, a pooled token's rules must be compatible with a pool and its -/// outgoing notes threshold within bounds. Returns the first error. Shared by the contract +/// outgoing notes threshold within bounds, and a tradeable token must be admitted and +/// transferable. Returns the first error. Shared by the contract /// create and update basic structure generations and by the pre-activation gate, so the three /// cannot drift. pub fn validate_token_configurations( @@ -195,6 +253,10 @@ pub fn validate_token_configurations( if !result.is_valid() { return result; } + let result = configuration.validate_marketplace_rules(*position, platform_version); + if !result.is_valid() { + return result; + } } SimpleConsensusValidationResult::new() } @@ -1124,3 +1186,101 @@ mod non_transferable_json_tests { assert_eq!(decoded, pooled); } } + +#[cfg(test)] +mod marketplace_rules_tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::codes::ErrorWithCode; + use crate::consensus::ConsensusError; + use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use crate::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Setters; + use crate::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; + + fn not_tradeable() -> TokenConfiguration { + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()) + } + + fn tradeable() -> TokenConfiguration { + let mut configuration = TokenConfigurationV0::default_most_restrictive(); + configuration + .marketplace_rules + .set_trade_mode(TokenTradeMode::Tradeable); + TokenConfiguration::V0(configuration) + } + + fn protocol_version_14() -> &'static PlatformVersion { + PlatformVersion::get(14).expect("expected protocol version 14") + } + + #[test] + fn should_accept_a_token_that_is_not_tradeable_at_every_protocol_version() { + let configurations = BTreeMap::from([(0, not_tradeable())]); + for platform_version in [protocol_version_14(), PlatformVersion::latest()] { + assert!(validate_token_configurations(&configurations, platform_version).is_valid()); + } + } + + #[test] + fn should_refuse_a_tradeable_token_as_not_active_before_protocol_version_15() { + let configurations = BTreeMap::from([(1, tradeable())]); + let result = validate_token_configurations(&configurations, protocol_version_14()); + match result.errors.as_slice() { + [error + @ ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(inner))] => { + assert_eq!(inner.state_transition_type(), "TokenTradeModeTradeable"); + assert_eq!(inner.current_protocol_version(), 14); + assert_eq!( + inner.required_protocol_version(), + TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION + ); + assert_eq!(error.code(), 10603); + } + other => panic!("expected StateTransitionNotActiveError, got {other:?}"), + } + } + + #[test] + fn should_accept_a_tradeable_transferable_token_from_protocol_version_15() { + let configurations = BTreeMap::from([(0, tradeable())]); + assert!( + validate_token_configurations(&configurations, PlatformVersion::latest()).is_valid() + ); + } + + #[test] + fn should_refuse_a_tradeable_non_transferable_token_with_error_10281() { + let mut configuration = tradeable(); + configuration.set_transferable(false); + let configurations = BTreeMap::from([(3, configuration)]); + let result = validate_token_configurations(&configurations, PlatformVersion::latest()); + match result.errors.as_slice() { + [error @ ConsensusError::BasicError(BasicError::NonTransferableTokenTradeableError( + inner, + ))] => { + assert_eq!(inner.token_contract_position(), 3); + assert_eq!(error.code(), 10281); + } + other => panic!("expected NonTransferableTokenTradeableError, got {other:?}"), + } + } + + /// A configuration update that makes a non-transferable token tradeable proposes the same + /// configuration a contract update would, and is refused by the same check. + #[test] + fn should_refuse_making_a_non_transferable_token_tradeable_through_its_configuration_item() { + let mut configuration = not_tradeable(); + configuration.set_transferable(false); + configuration.apply_token_configuration_item( + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + ); + let result = configuration.validate_marketplace_rules(0, PlatformVersion::latest()); + assert!(matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::NonTransferableTokenTradeableError(_) + )] + )); + } +} diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs index f14587c6fb1..b654adda583 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs @@ -70,8 +70,8 @@ mod json_convertible_tests { use crate::serialization::JsonConvertible; let original = fixture(); let json = original.to_json().expect("to_json"); - // `TokenTradeMode` is an externally-tagged enum with a single unit - // variant (`NotTradeable`) that serializes as a bare string. + // `TokenTradeMode` is an externally-tagged enum of unit variants + // (`NotTradeable`, `Tradeable`) that serialize as bare strings. // `ChangeControlRules` is a versioned enum with `tag = "$formatVersion"`, // and `AuthorizedActionTakers` unit variants serialize as bare strings. // No sized integers in this fixture — only Text + Bool. @@ -118,4 +118,42 @@ mod json_convertible_tests { let recovered = TokenMarketplaceRules::from_object(value).expect("from_object"); assert_eq!(original, recovered); } + + #[test] + fn should_serialize_tradeable_as_a_bare_string_on_both_wire_formats() { + use crate::serialization::{JsonConvertible, ValueConvertible}; + let mut original = fixture(); + let TokenMarketplaceRules::V0(v0) = &mut original; + v0.trade_mode = TokenTradeMode::Tradeable; + + let json = original.to_json().expect("to_json"); + assert_eq!(json["tradeMode"], json!("Tradeable")); + assert_eq!( + TokenMarketplaceRules::from_json(json).expect("from_json"), + original + ); + + let value = original.to_object().expect("to_object"); + assert_eq!( + value.get_str("tradeMode").expect("expected a tradeMode"), + "Tradeable" + ); + assert_eq!( + TokenMarketplaceRules::from_object(value).expect("from_object"), + original + ); + } + + /// `TokenTradeMode` is bincode-encoded positionally inside every stored token configuration + /// and every `MarketplaceTradeMode` configuration update, so its variants keep their tags: a + /// new mode goes after `Tradeable`. + #[test] + fn should_keep_the_bincode_tags_of_the_trade_modes() { + let tag_of = |mode: TokenTradeMode| { + bincode::encode_to_vec(mode, bincode::config::standard()) + .expect("expected to encode the trade mode") + }; + assert_eq!(tag_of(TokenTradeMode::NotTradeable), vec![0]); + assert_eq!(tag_of(TokenTradeMode::Tradeable), vec![1]); + } } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs index e4ff1d971af..370f9981d68 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs @@ -24,8 +24,24 @@ use std::fmt; DecodeUntrusted, )] pub enum TokenTradeMode { + /// Holders cannot list the token for sale or make offers for it through the platform. #[default] NotTradeable, + /// Holders may list the token at a price and make offers for it: listed tokens and offered + /// credits are held in escrow until the order is filled or cancelled. Admitted from protocol + /// version 15 (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`), and only on a transferable + /// token, since every fill moves the token from one identity to another. + Tradeable, +} + +impl TokenTradeMode { + /// Whether holders may trade the token through the platform's listings and offers. + pub fn is_tradeable(&self) -> bool { + match self { + TokenTradeMode::NotTradeable => false, + TokenTradeMode::Tradeable => true, + } + } } // Manual impls because TokenTradeMode is a flat enum (not versioned V0/V1). diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index c08c83d70c1..da94a676c73 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -38,12 +38,13 @@ use crate::consensus::basic::data_contract::{ InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, NonTransferableTokenPaymentMustBurnError, - NonTransferableTokenShieldedPoolError, PreProgrammedDistributionAmountOverLimitError, - RedundantDocumentPaidForByTokenWithContractId, SystemPropertyIndexAlreadyPresentError, - TokenShieldedPoolIncompatibleRulesError, UndefinedIndexPropertyError, - UniqueIndicesLimitReachedError, UnknownDocumentCreationRestrictionModeError, - UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, - UnknownTradeModeError, UnknownTransferableTypeError, + NonTransferableTokenShieldedPoolError, NonTransferableTokenTradeableError, + PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, + SystemPropertyIndexAlreadyPresentError, TokenShieldedPoolIncompatibleRulesError, + UndefinedIndexPropertyError, UniqueIndicesLimitReachedError, + UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, + UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, + UnknownTransferableTypeError, }; use crate::consensus::basic::data_contract::{ InvalidJsonSchemaRefError, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, @@ -864,6 +865,10 @@ pub enum BasicError { // instead of burning it (protocol version 14). #[error(transparent)] NonTransferableTokenPaymentMustBurnError(NonTransferableTokenPaymentMustBurnError), + + // A non-transferable token cannot be tradeable (protocol version 15). + #[error(transparent)] + NonTransferableTokenTradeableError(NonTransferableTokenTradeableError), } impl From for ConsensusError { @@ -1065,13 +1070,21 @@ mod tests { 204 ); // A document type paying its own non-transferable token to the contract owner - // (protocol version 14): the tail of the enum. + // (protocol version 14). assert_eq!( discriminant_of(BasicError::NonTransferableTokenPaymentMustBurnError( NonTransferableTokenPaymentMustBurnError::new(0, "create".to_string()) )), 205 ); + // A non-transferable token that is tradeable (protocol version 15): the tail of the + // enum. + assert_eq!( + discriminant_of(BasicError::NonTransferableTokenTradeableError( + NonTransferableTokenTradeableError::new(0) + )), + 206 + ); } /// The variants that shipped in 4.1 keep the discriminants they were released with, so an diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs index 290ee0f8c2c..b01fb75826a 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs @@ -48,6 +48,7 @@ mod non_contiguous_contract_group_positions_error; mod non_contiguous_contract_token_positions_error; mod non_transferable_token_payment_must_burn_error; mod non_transferable_token_shielded_pool_error; +mod non_transferable_token_tradeable_error; mod pre_programmed_distribution_amount_over_limit_error; mod redundant_document_paid_for_by_token_with_contract_id; mod system_property_index_already_present_error; @@ -118,6 +119,7 @@ pub use non_contiguous_contract_group_positions_error::*; pub use non_contiguous_contract_token_positions_error::*; pub use non_transferable_token_payment_must_burn_error::*; pub use non_transferable_token_shielded_pool_error::*; +pub use non_transferable_token_tradeable_error::*; pub use pre_programmed_distribution_amount_over_limit_error::*; pub use redundant_document_paid_for_by_token_with_contract_id::*; pub use token_decimals_over_limit_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_tradeable_error.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_tradeable_error.rs new file mode 100644 index 00000000000..48a41315a2a --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_tradeable_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::basic::BasicError; +use crate::data_contract::TokenContractPosition; +use crate::errors::ProtocolError; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +use crate::consensus::ConsensusError; + +use bincode::{Decode, DecodeUntrusted, Encode}; + +/// A non-transferable token cannot be tradeable: filling a listing or an offer moves the token +/// from the seller to the buyer, which is the transfer the token refuses. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token at position {token_contract_position} is not transferable, so it cannot be tradeable: filling a listing or an offer moves the token to another identity" +)] +#[platform_serialize(unversioned)] +pub struct NonTransferableTokenTradeableError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_contract_position: TokenContractPosition, +} + +impl NonTransferableTokenTradeableError { + pub fn new(token_contract_position: TokenContractPosition) -> Self { + Self { + token_contract_position, + } + } + + pub fn token_contract_position(&self) -> TokenContractPosition { + self.token_contract_position + } +} + +impl From for ConsensusError { + fn from(err: NonTransferableTokenTradeableError) -> Self { + Self::BasicError(BasicError::NonTransferableTokenTradeableError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index afdd2339066..b50505b3fe5 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -125,6 +125,7 @@ impl ErrorWithCode for BasicError { Self::TokenShieldedPoolIncompatibleRulesError(_) => 10278, Self::NonTransferableTokenShieldedPoolError(_) => 10279, Self::NonTransferableTokenPaymentMustBurnError(_) => 10280, + Self::NonTransferableTokenTradeableError(_) => 10281, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index d1d6e350e17..7bf11d9ae27 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -23,7 +23,7 @@ use dpp::version::feature_initial_protocol_versions::{ IDENTITY_KEY_LIMITS_UPDATE_INITIAL_PROTOCOL_VERSION, IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, - TOKEN_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, TOKEN_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, }; use dpp::version::PlatformVersion; @@ -62,28 +62,28 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) | StateTransition::ContractFeeClaim(_) => Ok(true), - // Newly decoded token formats need an unpaid activation check even while the - // older contract basic-structure generations remain frozen. + // Newly decoded token formats and trade modes need an unpaid activation check even + // while the older contract basic-structure generations remain frozen. // // This predicate is load-bearing rather than a shortcut: contract basic structure // validation 0 and 1, which protocol versions 9 through 13 select, walk the token // configurations through the version 0 accessors and never ask what format version - // they carry. On those versions the format-version check `validate_is_allowed` runs - // is the only one there is, and it is reached only where this returns true, so a - // token configuration format they do not admit is refused here or nowhere. - // Narrowing the predicate means first teaching both of those frozen generations to - // validate token configurations. Generation 2 does validate them, which makes this - // redundant from protocol version 14 on, and only from there. + // or trade mode they carry. On those versions the checks `validate_is_allowed` runs + // are the only ones there are, and they are reached only where this returns true, + // so a token configuration format or trade mode they do not admit is refused here + // or nowhere. Narrowing the predicate means first teaching both of those frozen + // generations to validate token configurations. Generation 2 does validate them, + // which makes this redundant from protocol version 14 on, and only from there. StateTransition::DataContractCreate(st) => Ok(st .data_contract() .tokens() .values() - .any(|configuration| configuration.format_version() > 0)), + .any(|configuration| configuration.needs_activation_check())), StateTransition::DataContractUpdate(st) => Ok(st .data_contract() .tokens() .values() - .any(|configuration| configuration.format_version() > 0)), + .any(|configuration| configuration.needs_activation_check())), StateTransition::IdentityCreate(_) | StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditWithdrawal(_) @@ -168,8 +168,41 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { ])); } } - // A batch needs no further `is_allowed` check: the token shielded pool version - // gate above is the only one it carries. + if platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { + // The token marketplace: a configuration update that makes a token + // tradeable. Software older than the marketplace cannot decode one, so it + // is refused unpaid, as that software refuses it. + if let Some(transition) = + st.transitions_iter() + .find_map(|transition| match transition { + BatchedTransitionRef::Token(TokenTransition::ConfigUpdate( + config_update, + )) if matches!( + config_update.update_token_configuration_item(), + TokenConfigurationChangeItem::MarketplaceTradeMode(mode) + if mode.is_tradeable() + ) => + { + Some( + "TokenConfigUpdateMarketplaceTradeModeTradeable" + .to_string(), + ) + } + _ => None, + }) + { + return Ok(ConsensusValidationResult::new_with_errors(vec![ + StateTransitionNotActiveError::new( + transition, + platform_version.protocol_version, + TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ])); + } + } + // A batch needs no further `is_allowed` check: the token shielded pool and the + // token marketplace version gates above are the only ones it carries. Ok(ConsensusValidationResult::new()) } StateTransition::IdentityTopUpFromAddresses(_) @@ -740,4 +773,155 @@ mod tests { } } } + + mod token_marketplace_gate { + use super::*; + use assert_matches::assert_matches; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v1::DataContractV1Setters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Setters; + use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; + use dpp::platform_value::BinaryData; + use dpp::prelude::IdentityNonce; + use dpp::state_transition::batch_transition::batched_transition::token_config_update_transition::{TokenConfigUpdateTransition, TokenConfigUpdateTransitionV0}; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; + use dpp::state_transition::batch_transition::BatchTransitionV1; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use platform_version::TryIntoPlatformVersioned; + + fn protocol_version_14() -> &'static PlatformVersion { + PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION - 1) + .expect("the version before the token marketplace") + } + + /// A batch whose only transition sets a token's trade mode to `trade_mode`. + fn trade_mode_update(trade_mode: TokenTradeMode) -> StateTransition { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Default::default(), + transitions: vec![BatchedTransition::Token(TokenTransition::ConfigUpdate( + TokenConfigUpdateTransition::V0(TokenConfigUpdateTransitionV0 { + base: Default::default(), + update_token_configuration_item: + TokenConfigurationChangeItem::MarketplaceTradeMode(trade_mode), + public_note: None, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::default(), + })) + } + + /// A contract create whose only token has `trade_mode`, in a format version 0 + /// configuration, so the trade mode is the only thing that can call for the check. + fn contract_create_with_trade_mode( + trade_mode: TokenTradeMode, + platform_version: &PlatformVersion, + ) -> StateTransition { + let identity_nonce = IdentityNonce::default(); + let mut data_contract = + get_data_contract_fixture(None, identity_nonce, platform_version.protocol_version) + .data_contract_owned(); + let mut configuration = TokenConfigurationV0::default_most_restrictive(); + configuration.marketplace_rules.set_trade_mode(trade_mode); + data_contract.add_token(0, TokenConfiguration::V0(configuration)); + StateTransition::DataContractCreate( + DataContractCreateTransitionV0 { + data_contract: data_contract + .try_into_platform_versioned(platform_version) + .expect("failed to convert data contract"), + identity_nonce, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(), + ) + } + + #[test] + fn should_refuse_making_a_token_tradeable_before_protocol_version_15() { + let result = trade_mode_update(TokenTradeMode::Tradeable) + .validate_is_allowed(protocol_version_14()) + .expect("should not error"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.state_transition_type() + == "TokenConfigUpdateMarketplaceTradeModeTradeable" + && error.current_protocol_version() == 14 + && error.required_protocol_version() + == TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION + ); + } + + /// Setting the trade mode to the only mode older software knows stays allowed. + #[test] + fn should_allow_setting_the_trade_mode_to_not_tradeable_before_protocol_version_15() { + let result = trade_mode_update(TokenTradeMode::NotTradeable) + .validate_is_allowed(protocol_version_14()) + .expect("should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + + #[test] + fn should_allow_making_a_token_tradeable_from_protocol_version_15() { + let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) + .expect("the version that introduced the token marketplace"); + let result = trade_mode_update(TokenTradeMode::Tradeable) + .validate_is_allowed(platform_version) + .expect("should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + + /// A format version 0 configuration needs the check only when it is tradeable: the + /// frozen contract basic structure generations of protocol versions 9 to 13 never read + /// the trade mode, so this check is the only one they have. + #[test] + fn should_run_the_check_on_a_contract_with_a_tradeable_token() { + let platform_version = PlatformVersion::latest(); + assert!( + contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) + .has_is_allowed_validation() + .expect("should not error") + ); + assert!(!contract_create_with_trade_mode( + TokenTradeMode::NotTradeable, + platform_version + ) + .has_is_allowed_validation() + .expect("should not error")); + } + + #[test] + fn should_refuse_a_contract_with_a_tradeable_token_before_protocol_version_15() { + let platform_version = protocol_version_14(); + let result = + contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) + .validate_is_allowed(platform_version) + .expect("should not error"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.state_transition_type() == "TokenTradeModeTradeable" + && error.required_protocol_version() + == TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION + ); + } + + #[test] + fn should_allow_a_contract_with_a_tradeable_token_from_protocol_version_15() { + let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) + .expect("the version that introduced the token marketplace"); + let result = + contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) + .validate_is_allowed(platform_version) + .expect("should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs index fdd057560ca..0a06b8065de 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs @@ -9,10 +9,12 @@ use crate::error::execution::ExecutionError; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v0::TokenConfigUpdateTransitionActionStateValidationV0; use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v1::TokenConfigUpdateTransitionActionStateValidationV1; +use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v2::TokenConfigUpdateTransitionActionStateValidationV2; use crate::platform_types::platform::PlatformStateRef; mod state_v0; mod state_v1; +mod state_v2; pub trait TokenConfigUpdateTransitionActionValidation { fn validate_state( @@ -59,9 +61,17 @@ impl TokenConfigUpdateTransitionActionValidation for TokenConfigUpdateTransition transaction, platform_version, ), + 2 => self.validate_state_v2( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "TokenConfigUpdateTransitionAction::validate_state".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs new file mode 100644 index 00000000000..1a99f56b157 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs @@ -0,0 +1,65 @@ +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::prelude::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_config_update_transition_action::{ + TokenConfigUpdateTransitionAction, TokenConfigUpdateTransitionActionAccessorsV0, +}; + +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v1::TokenConfigUpdateTransitionActionStateValidationV1; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenConfigUpdateTransitionActionStateValidationV2 { + fn validate_state_v2( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenConfigUpdateTransitionActionStateValidationV2 for TokenConfigUpdateTransitionAction { + /// v1, then the marketplace rules of the configuration the update proposes (protocol version + /// 15): a `MarketplaceTradeMode` item can make a token tradeable only if the token is + /// transferable (`NonTransferableTokenTradeableError`, 10281), the rule contract create and + /// update apply to the configuration they carry. `transferable` is fixed at creation, so the + /// trade mode is the only half of the rule an update can change. + fn validate_state_v2( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.validate_state_v1( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let contract = &self.data_contract_fetch_info_ref().contract; + let mut proposed_token_configuration = contract + .expected_token_configuration(self.token_position())? + .clone(); + proposed_token_configuration + .apply_token_configuration_item(self.update_token_configuration_item().clone()); + + Ok(proposed_token_configuration + .validate_marketplace_rules(self.token_position(), platform_version)) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs index 8413bfd6341..c60af355c30 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs @@ -579,3 +579,148 @@ async fn should_still_hand_a_non_transferable_token_out_by_mint_claim_and_purcha assert_eq!(balance, Some(expected)); } } + +mod tradeable { + use super::*; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use dpp::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Getters; + use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; + + /// Lets the contract owner change the token's trade mode, and makes the token + /// non-transferable when asked. + fn owner_sets_trade_mode(non_transferable: bool) -> impl FnOnce(&mut TokenConfiguration) { + move |token_configuration: &mut TokenConfiguration| { + if non_transferable { + make_token_non_transferable(token_configuration); + } + token_configuration.apply_token_configuration_item( + TokenConfigurationChangeItem::MarketplaceTradeModeControlGroup( + AuthorizedActionTakers::ContractOwner, + ), + ); + } + } + + /// Sends the contract owner's update of the token's trade mode to `Tradeable` in one block, + /// and returns its result with the trade mode the token has afterwards. + async fn make_tradeable(non_transferable: bool) -> (StateTransitionExecutionResult, bool) { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(owner_sets_trade_mode(non_transferable)), + None, + None, + None, + platform_version, + ); + + let config_update_transition = BatchTransition::new_token_config_update_transition( + token_id, + identity.id(), + contract.id(), + 0, + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let config_update_serialized_transition = config_update_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[config_update_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let contract = platform + .drive + .fetch_contract( + contract.id().to_buffer(), + None, + None, + None, + platform_version, + ) + .unwrap() + .expect("expected to fetch the contract") + .expect("expected the contract"); + let is_tradeable = contract + .contract + .expected_token_configuration(0) + .expect("expected the token configuration") + .as_cow_v0() + .marketplace_rules + .trade_mode() + .is_tradeable(); + + let result = processing_result + .into_execution_results() + .pop() + .expect("expected one execution result"); + (result, is_tradeable) + } + + #[tokio::test] + async fn should_refuse_making_a_non_transferable_token_tradeable_with_a_paid_error_10281() { + let (result, is_tradeable) = make_tradeable(true).await; + assert_matches!( + result, + PaidConsensusError { + error: error @ ConsensusError::BasicError( + BasicError::NonTransferableTokenTradeableError(_) + ), + .. + } if error.code() == 10281 + ); + assert!(!is_tradeable); + } + + #[tokio::test] + async fn should_make_a_transferable_token_tradeable_from_protocol_version_15() { + let (result, is_tradeable) = make_tradeable(false).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(is_tradeable); + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 9adfc83c50d..7d0733c37e3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -15,6 +15,9 @@ use super::{ /// - `create_domain_data_trigger` 2, which keeps only the parent domain checks of a DPNS domain /// create: the DPNS contract v3 (`SYSTEM_DATA_CONTRACT_VERSIONS_V4`) checks the normalized /// names, the preorder and the identity record with schema keywords. +/// - `token_config_update_transition_state_validation` 2, which also checks the marketplace rules +/// of the configuration an update proposes: a token can be made tradeable only if it is +/// transferable. /// /// v10 remains unchanged for `PROTOCOL_VERSION_14` chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = @@ -27,6 +30,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = .identity_update_state_transition }, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { + token_config_update_transition_state_validation: 2, // a tradeable token must be transferable data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { triggers: DriveAbciValidationDataTriggerVersions { create_domain_data_trigger: 2, diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 8950f479d8d..39632330672 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -30,3 +30,8 @@ pub const TOKEN_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; /// version 14 and registered at genesis from that version on; below it the contract does not /// exist and lookups must report it absent. pub const MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; +/// The token marketplace (a token whose `marketplaceRules.tradeMode` is `Tradeable`, whose holders +/// sell it to each other through escrowed listings and offers) activates with protocol version 15. +/// Below it a token configuration or a configuration update that makes a token tradeable is +/// refused without a fee, as software that predates the marketplace cannot decode one. +pub const TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index afca63d6a52..c71af01508c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -125,6 +125,17 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// or sum tree holds the key as `serialize_value_for_key` writes it, so /// consensus reads the total as it reads any other. `parse_property_constraints` /// 0 (protocol version 14) reads such a binding as an integer and refuses it. +/// +/// 5. **Tradeable tokens**: `TokenTradeMode::Tradeable` +/// (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`) lets a token's holders list it and make +/// offers for it. Before this version a contract whose token is tradeable, and a +/// `TokenConfigUpdate` that makes a token tradeable, are refused unpaid as not active +/// (`StateTransitionNotActiveError`, 10603), the refusal older software gives by failing to +/// decode the mode. A tradeable token must be transferable (`NonTransferableTokenTradeableError`, +/// 10281): contract create and update refuse it unpaid, and +/// `token_config_update_transition_state_validation` 2 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the configuration an update proposes, charged. +/// Protocol v14 and earlier keep `token_config_update_transition_state_validation` 1. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 3f6db9fc05a..9615e50e313 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -66,7 +66,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ DataTriggerConditionError, DataTriggerExecutionError, DataTriggerInvalidResultError, }; use wasm_bindgen::{JsError, JsValue}; -use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractInvalidRequiredFieldsUpdateError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, NonTransferableTokenPaymentMustBurnError, NonTransferableTokenShieldedPoolError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TokenShieldedPoolIncompatibleRulesError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; +use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractInvalidRequiredFieldsUpdateError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, NonTransferableTokenPaymentMustBurnError, NonTransferableTokenShieldedPoolError, NonTransferableTokenTradeableError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TokenShieldedPoolIncompatibleRulesError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyConstraintViolatedError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, DocumentPropertyNotGeneratedError, DocumentReferencePreimageInvalidError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; @@ -1421,6 +1421,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::NonTransferableTokenPaymentMustBurnError(e) => { generic_consensus_error!(NonTransferableTokenPaymentMustBurnError, e).into() } + BasicError::NonTransferableTokenTradeableError(e) => { + generic_consensus_error!(NonTransferableTokenTradeableError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs index 484f231b349..43c6dba93bc 100644 --- a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs +++ b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs @@ -25,10 +25,17 @@ impl TokenTradeModeWasm { TokenTradeModeWasm(TokenTradeMode::NotTradeable) } + /// Holders may list the token and make offers for it (protocol version 15). + #[wasm_bindgen(js_name = "Tradeable")] + pub fn tradeable() -> TokenTradeModeWasm { + TokenTradeModeWasm(TokenTradeMode::Tradeable) + } + #[wasm_bindgen(getter = "value")] pub fn value(&self) -> String { match self.0 { TokenTradeMode::NotTradeable => String::from("NotTradeable"), + TokenTradeMode::Tradeable => String::from("Tradeable"), } } } diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 0123b0f8f67..2006c7cb2a5 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -420,4 +420,31 @@ describe('TokenConfiguration', () => { })).to.throw(/hasShieldedPool/); }); }); + + describe('tradeMode', () => { + it('should name both trade modes', () => { + expect(wasm.TokenTradeMode.NotTradeable().value).to.equal('NotTradeable'); + expect(wasm.TokenTradeMode.Tradeable().value).to.equal('Tradeable'); + }); + + it('should keep a tradeable mode in the marketplace rules', () => { + const changeRules = new wasm.ChangeControlRules({ + authorizedToMakeChange: wasm.AuthorizedActionTakers.ContractOwner(), + adminActionTakers: wasm.AuthorizedActionTakers.NoOne(), + isChangingAuthorizedActionTakersToNoOneAllowed: false, + isChangingAdminActionTakersToNoOneAllowed: false, + isSelfChangingAdminActionTakersAllowed: false, + }); + const marketplaceRules = new wasm.TokenMarketplaceRules( + wasm.TokenTradeMode.Tradeable(), + changeRules, + ); + + expect(marketplaceRules.tradeMode.value).to.equal('Tradeable'); + + marketplaceRules.tradeMode = wasm.TokenTradeMode.NotTradeable(); + + expect(marketplaceRules.tradeMode.value).to.equal('NotTradeable'); + }); + }); }); diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index b7b468c4fd8..2751f3b4d8b 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -630,7 +630,6 @@ describe('TokenTransitions', () => { }); it('should set publicNote', () => { - // At this moment available only one trade mode const tradeMode = wasm.TokenTradeMode.NotTradeable(); const configUpdateTransition = new wasm.TokenConfigUpdateTransition({ base: baseTransition, updateTokenConfigurationItem: wasm.TokenConfigurationChangeItem.MarketplaceTradeModeItem(tradeMode), publicNote: 'bbbb' }); From e3aceb194088035b68a2a74bdd0c254af2ea0ecb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 21:32:58 +0300 Subject: [PATCH 33/62] feat(platform)!: token marketplace escrow trees and token market contract (PV15) Prepares the state of the token marketplace at protocol version 15: - Two escrow trees, built by one helper at genesis (create_initial_state_structure 5) and by transition_to_version_15: [Tokens, TOKEN_ORDER_ESCROW_KEY], a BigSumTree of one SumTree per token of the tokens open listings hold, and [PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS], a SumTree of the credits open offers hold, with add, set, remove and fetch primitives and their cost estimation. - calculate_total_tokens_balance 2 counts the escrowed tokens on the balance side of the token conservation check (TotalTokensBalance gains total_token_order_escrow_balances). - The token market system contract (ask and bid, indexed by book, depth and owner, depth summing what each side escrows), registered at genesis from 15 and stored without storage flags by the upgrade; data trigger bindings 3 refuse a document transition's replace or delete of an order. Co-Authored-By: Claude Opus 5.5 --- .codecov.yml | 1 + .../package-filters/js-packages-direct.yml | 3 + .../js-packages-no-workflows.yml | 4 + .github/package-filters/js-packages.yml | 5 + .../package-filters/rs-packages-direct.yml | 5 + .../rs-packages-no-workflows.yml | 6 + .github/package-filters/rs-packages.yml | 7 + .../package-filters/test-suite-triggers.yml | 1 + .github/workflows/tests-rs-workspace.yml | 1 + .github/workflows/tests.yml | 1 + .pnp.cjs | 21 ++ Cargo.lock | 12 + Cargo.toml | 1 + Dockerfile | 5 + book/src/architecture/overview.md | 2 +- book/src/data-model/token-marketplace.md | 32 +++ package.json | 2 + packages/data-contracts/Cargo.toml | 3 + packages/data-contracts/src/error.rs | 18 ++ packages/data-contracts/src/lib.rs | 30 ++- packages/rs-dpp/Cargo.toml | 2 + .../src/balances/total_tokens_balance/mod.rs | 66 +++++- packages/rs-dpp/src/system_data_contracts.rs | 79 +++++++ .../create_genesis_state/v1/mod.rs | 53 +++++ .../v0/mod.rs | 183 ++++++++++++++- .../batch/data_triggers/bindings/list/mod.rs | 7 +- .../data_triggers/bindings/list/v3/mod.rs | 66 ++++++ .../batch/tests/document/deletion.rs | 5 +- .../batch/tests/document/dpns.rs | 6 +- .../batch/tests/document/dpns_v3.rs | 6 +- .../batch/tests/document/replacement.rs | 5 +- .../token/shielded/nullifier_batch_guard.rs | 20 +- packages/rs-drive/grovedb-structure.json | 114 +++++++++- .../rs-drive/src/cache/system_contracts.rs | 33 +++ .../rs-drive/src/drive/initialization/mod.rs | 4 +- .../src/drive/initialization/v5/mod.rs | 29 +++ .../prefunded_specialized_balances/mod.rs | 21 ++ .../structure.rs | 27 ++- .../calculate_total_tokens_balance/mod.rs | 4 +- .../calculate_total_tokens_balance/v0/mod.rs | 1 + .../calculate_total_tokens_balance/v1/mod.rs | 2 + .../calculate_total_tokens_balance/v2/mod.rs | 71 ++++++ packages/rs-drive/src/drive/tokens/mod.rs | 5 + .../drive/tokens/order_escrow/credits/mod.rs | 137 ++++++++++++ .../tokens/order_escrow/credits/v0/mod.rs | 127 +++++++++++ .../order_escrow/estimated_costs/mod.rs | 65 ++++++ .../order_escrow/estimated_costs/v0/mod.rs | 112 ++++++++++ .../drive/tokens/order_escrow/insert_trees.rs | 53 +++++ .../src/drive/tokens/order_escrow/mod.rs | 211 ++++++++++++++++++ .../drive/tokens/order_escrow/tokens/mod.rs | 137 ++++++++++++ .../tokens/order_escrow/tokens/v0/mod.rs | 155 +++++++++++++ packages/rs-drive/src/drive/tokens/paths.rs | 42 ++++ .../rs-drive/src/drive/tokens/structure.rs | 34 ++- packages/rs-drive/src/structure/tests.rs | 37 +++ .../drive_abci_validation_versions/v11.rs | 8 +- .../drive_token_method_versions/mod.rs | 18 ++ .../drive_token_method_versions/v1.rs | 17 +- .../drive_token_method_versions/v3.rs | 13 ++ .../src/version/drive_versions/mod.rs | 1 + .../src/version/drive_versions/v10.rs | 29 +++ .../system_data_contract_versions/mod.rs | 3 + .../system_data_contract_versions/v1.rs | 1 + .../system_data_contract_versions/v2.rs | 1 + .../system_data_contract_versions/v3.rs | 1 + .../system_data_contract_versions/v4.rs | 7 +- .../rs-platform-version/src/version/v15.rs | 17 +- packages/rs-sdk-ffi/Cargo.toml | 1 + .../Cargo.toml | 2 + .../src/provider.rs | 50 +++++ packages/rs-sdk/Cargo.toml | 1 + packages/token-market-contract/.mocharc.yml | 2 + packages/token-market-contract/Cargo.toml | 15 ++ packages/token-market-contract/LICENSE | 20 ++ packages/token-market-contract/README.md | 61 +++++ .../token-market-contract/eslint.config.mjs | 10 + .../token-market-contract/lib/systemIds.js | 4 + packages/token-market-contract/package.json | 27 +++ .../v1/token-market-contract-documents.json | 209 +++++++++++++++++ packages/token-market-contract/src/error.rs | 17 ++ packages/token-market-contract/src/lib.rs | 143 ++++++++++++ packages/token-market-contract/src/v1/mod.rs | 53 +++++ .../token-market-contract/test/bootstrap.js | 30 +++ .../test/unit/tokenMarketContract.spec.js | 103 +++++++++ packages/wasm-sdk/Cargo.toml | 5 + yarn.lock | 14 ++ 85 files changed, 2914 insertions(+), 48 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v3/mod.rs create mode 100644 packages/rs-drive/src/drive/initialization/v5/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v2/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/credits/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/insert_trees.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/v10.rs create mode 100644 packages/token-market-contract/.mocharc.yml create mode 100644 packages/token-market-contract/Cargo.toml create mode 100644 packages/token-market-contract/LICENSE create mode 100644 packages/token-market-contract/README.md create mode 100644 packages/token-market-contract/eslint.config.mjs create mode 100644 packages/token-market-contract/lib/systemIds.js create mode 100644 packages/token-market-contract/package.json create mode 100644 packages/token-market-contract/schema/v1/token-market-contract-documents.json create mode 100644 packages/token-market-contract/src/error.rs create mode 100644 packages/token-market-contract/src/lib.rs create mode 100644 packages/token-market-contract/src/v1/mod.rs create mode 100644 packages/token-market-contract/test/bootstrap.js create mode 100644 packages/token-market-contract/test/unit/tokenMarketContract.spec.js diff --git a/.codecov.yml b/.codecov.yml index ca5bab92ed9..514f6a28615 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -55,6 +55,7 @@ ignore: - "packages/keyword-search-contract/src/**" - "packages/app-connect-contract/src/**" - "packages/moderation-charters-contract/src/**" + - "packages/token-market-contract/src/**" - "packages/masternode-reward-shares-contract/src/**" - "packages/token-history-contract/src/**" - "packages/wallet-utils-contract/src/**" diff --git a/.github/package-filters/js-packages-direct.yml b/.github/package-filters/js-packages-direct.yml index b170753427f..9e5a9df7300 100644 --- a/.github/package-filters/js-packages-direct.yml +++ b/.github/package-filters/js-packages-direct.yml @@ -16,6 +16,9 @@ '@dashevo/moderation-charters-contract': - packages/moderation-charters-contract/** +'@dashevo/token-market-contract': + - packages/token-market-contract/** + '@dashevo/dashpay-contract': - packages/dashpay-contract/** diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index ac39c720953..ee786f1f000 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -16,6 +16,9 @@ '@dashevo/moderation-charters-contract': &moderation-charters-contract - packages/moderation-charters-contract/** +'@dashevo/token-market-contract': &token-market-contract + - packages/token-market-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - packages/dashpay-contract/** @@ -43,6 +46,7 @@ - *keyword-search-contract - *app-connect-contract - *moderation-charters-contract + - *token-market-contract - packages/rs-platform-serialization/** - packages/rs-platform-serialization-derive/** - packages/rs-platform-value/** diff --git a/.github/package-filters/js-packages.yml b/.github/package-filters/js-packages.yml index 550e5633385..f0c0f4227d4 100644 --- a/.github/package-filters/js-packages.yml +++ b/.github/package-filters/js-packages.yml @@ -22,6 +22,10 @@ - .github/workflows/tests* - packages/moderation-charters-contract/** +'@dashevo/token-market-contract': &token-market-contract + - .github/workflows/tests* + - packages/token-market-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/** @@ -55,6 +59,7 @@ - *keyword-search-contract - *app-connect-contract - *moderation-charters-contract + - *token-market-contract - packages/rs-platform-serialization/** - packages/rs-platform-serialization-derive/** - packages/rs-platform-value/** diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index b74139a2523..ab9e15fd89d 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -28,6 +28,11 @@ moderation-charters-contract: - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +token-market-contract: + - packages/token-market-contract/src/** + - packages/token-market-contract/schema/** + - packages/token-market-contract/Cargo.toml + dashpay-contract: - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 5c59193d383..cfdb416584c 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -28,6 +28,11 @@ moderation-charters-contract: &moderation-charters-contract - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +token-market-contract: &token-market-contract + - packages/token-market-contract/src/** + - packages/token-market-contract/schema/** + - packages/token-market-contract/Cargo.toml + dashpay-contract: &dashpay-contract - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** @@ -63,6 +68,7 @@ data-contracts: &data-contracts - *document-history-contract - *app-connect-contract - *moderation-charters-contract + - *token-market-contract dpp: &dpp - packages/rs-dpp/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 91dd8c681ba..8913e26d92d 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -34,6 +34,12 @@ moderation-charters-contract: &moderation-charters-contract - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +token-market-contract: &token-market-contract + - .github/workflows/tests* + - packages/token-market-contract/src/** + - packages/token-market-contract/schema/** + - packages/token-market-contract/Cargo.toml + dashpay-contract: &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/src/** @@ -75,6 +81,7 @@ data-contracts: &data-contracts - *document-history-contract - *app-connect-contract - *moderation-charters-contract + - *token-market-contract dpp: &dpp - .github/workflows/tests* diff --git a/.github/package-filters/test-suite-triggers.yml b/.github/package-filters/test-suite-triggers.yml index 778c99990ba..2ef65eb74dc 100644 --- a/.github/package-filters/test-suite-triggers.yml +++ b/.github/package-filters/test-suite-triggers.yml @@ -32,6 +32,7 @@ run: - packages/keyword-search-contract/** - packages/app-connect-contract/** - packages/moderation-charters-contract/** + - packages/token-market-contract/** - packages/wallet-utils-contract/** # Local network scripts and action - .github/actions/local-network/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index fe3988d872c..1dbaabcec16 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -476,6 +476,7 @@ jobs: --package keyword-search-contract \ --package app-connect-contract \ --package moderation-charters-contract \ + --package token-market-contract \ --all-features \ --locked \ -E '(not test(~shield) or (package(=platform-wallet-storage) and not test(~wallet::shielded::))) and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations) or test(~process_proposal_collision))' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70c7841cf20..9c062af0033 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -342,6 +342,7 @@ jobs: - packages/swift-sdk/** - packages/app-connect-contract/** - packages/moderation-charters-contract/** + - packages/token-market-contract/** - packages/dapi-grpc/** - packages/dashpay-contract/** - packages/data-contracts/** diff --git a/.pnp.cjs b/.pnp.cjs index 3bb1467bbb3..2497b0037d0 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -86,6 +86,10 @@ const RAW_RUNTIME_STATE = "name": "@dashevo/token-history-contract",\ "reference": "workspace:packages/token-history-contract"\ },\ + {\ + "name": "@dashevo/token-market-contract",\ + "reference": "workspace:packages/token-market-contract"\ + },\ {\ "name": "@dashevo/wallet-lib",\ "reference": "workspace:packages/wallet-lib"\ @@ -136,6 +140,7 @@ const RAW_RUNTIME_STATE = ["@dashevo/platform", ["workspace:."]],\ ["@dashevo/platform-test-suite", ["workspace:packages/platform-test-suite"]],\ ["@dashevo/token-history-contract", ["workspace:packages/token-history-contract"]],\ + ["@dashevo/token-market-contract", ["workspace:packages/token-market-contract"]],\ ["@dashevo/wallet-lib", ["workspace:packages/wallet-lib"]],\ ["@dashevo/wallet-utils-contract", ["workspace:packages/wallet-utils-contract"]],\ ["@dashevo/wasm-dpp", ["workspace:packages/wasm-dpp"]],\ @@ -3072,6 +3077,22 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@dashevo/token-market-contract", [\ + ["workspace:packages/token-market-contract", {\ + "packageLocation": "./packages/token-market-contract/",\ + "packageDependencies": [\ + ["@dashevo/token-market-contract", "workspace:packages/token-market-contract"],\ + ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ + ["chai", "npm:4.3.10"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ + ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ + ["mocha", "npm:11.1.0"],\ + ["sinon", "npm:18.0.1"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@dashevo/wallet-lib", [\ ["workspace:packages/wallet-lib", {\ "packageLocation": "./packages/wallet-lib/",\ diff --git a/Cargo.lock b/Cargo.lock index 1a5f036177b..4f6a524ce37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2141,6 +2141,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "token-history-contract", + "token-market-contract", "wallet-utils-contract", "withdrawals-contract", ] @@ -8013,6 +8014,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "token-market-contract" +version = "5.0.0-rc.4" +dependencies = [ + "base58", + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "tokio" version = "1.52.3" diff --git a/Cargo.toml b/Cargo.toml index c5de8df04ee..c982f2aef16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ members = [ "packages/keyword-search-contract", "packages/app-connect-contract", "packages/moderation-charters-contract", + "packages/token-market-contract", "packages/rs-sdk-ffi", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", diff --git a/Dockerfile b/Dockerfile index 699d538122c..08f46187c1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -405,6 +405,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/token-market-contract \ packages/data-contracts \ packages/strategy-tests \ packages/simple-signer \ @@ -529,6 +530,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/token-market-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ @@ -713,6 +715,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/token-market-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ packages/data-contracts \ @@ -860,6 +863,7 @@ COPY --from=build-dashmate-helper /platform/packages/document-history-contract p COPY --from=build-dashmate-helper /platform/packages/keyword-search-contract packages/keyword-search-contract COPY --from=build-dashmate-helper /platform/packages/app-connect-contract packages/app-connect-contract COPY --from=build-dashmate-helper /platform/packages/moderation-charters-contract packages/moderation-charters-contract +COPY --from=build-dashmate-helper /platform/packages/token-market-contract packages/token-market-contract COPY --from=build-dashmate-helper /platform/packages/withdrawals-contract packages/withdrawals-contract COPY --from=build-dashmate-helper /platform/packages/masternode-reward-shares-contract packages/masternode-reward-shares-contract COPY --from=build-dashmate-helper /platform/packages/dpns-contract packages/dpns-contract @@ -965,6 +969,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/token-market-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ diff --git a/book/src/architecture/overview.md b/book/src/architecture/overview.md index d001c411ec7..d5d3d5da23b 100644 --- a/book/src/architecture/overview.md +++ b/book/src/architecture/overview.md @@ -281,7 +281,7 @@ Here is a simplified view of every Rust workspace member, grouped by role: | **gRPC definitions** | `dapi-grpc` | | **WASM bindings** | `wasm-dpp`, `wasm-dpp2`, `wasm-sdk`, `wasm-drive-verify` | | **iOS/FFI** | `rs-sdk-ffi` | -| **System contracts** | `dpns-contract`, `dashpay-contract`, `withdrawals-contract`, `masternode-reward-shares-contract`, `wallet-utils-contract`, `token-history-contract`, `keyword-search-contract`, `document-history-contract`, `app-connect-contract`, `moderation-charters-contract`, `data-contracts` | +| **System contracts** | `dpns-contract`, `dashpay-contract`, `withdrawals-contract`, `masternode-reward-shares-contract`, `wallet-utils-contract`, `token-history-contract`, `keyword-search-contract`, `document-history-contract`, `app-connect-contract`, `moderation-charters-contract`, `token-market-contract`, `data-contracts` | | **Tooling** | `dashmate` (JS), `strategy-tests`, `simple-signer`, `check-features`, `json-schema-compatibility-validator` | | **Other** | `dash-platform-macros`, `rs-dash-event-bus`, `rs-platform-wallet`, `dash-platform-balance-checker`, `rs-dapi` | diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 7a1c871797f..4741bbbaddd 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -49,3 +49,35 @@ created tradeable becomes tradeable only if its issuer kept the right to change A non-transferable token cannot be tradeable because filling a listing or an offer moves the token from the seller to the buyer, which is the transfer the token refuses. `transferable` is fixed when the token is created, so the trade mode is the only half of the rule an update can change. + +## Where orders and escrow live + +Open orders are documents of the token market system contract +(`AE6yYq65VdfGCg2MSyV9vV7cWGiFWjbwBuy7THesDh4e`), registered at genesis from protocol version 15 +and inserted by the upgrade to 15. It has two document types, `ask` (a listing) and `bid` (an +offer), each with three indexes: + +| Index | Properties | Serves | +|---|---|---| +| `book` | `tokenId`, `price`, `$createdAt` | A token's order book, by price and then by age | +| `depth` | `tokenId`, `price`, summed | The total open at each price, or over a price range, in one proven sum query: the base units of the asks (`remaining`) and the credits of the bids (`escrow`) | +| `byOwner` | `$ownerId`, `tokenId`, `$createdAt` | An identity's open orders | + +Each side sums what it holds in escrow, so the sums cannot overflow: the tokens listed never exceed +the token's supply, and the credits offered never exceed the credits on Platform. Every integer of +an order is a signed 8-byte value, so a partial fill rewrites an order without changing its stored +size. + +Only the marketplace transitions write orders. A document transition cannot create one +(`creationRestrictionMode` 2), and a replace or a delete of one is refused by a data trigger. The +types stay mutable and deletable so the documents carry a revision and storage flags: the order's +owner pays its storage and gets it back when the order closes. + +What an order holds is kept outside the contract: + +- The tokens of a listing sit in `[Tokens, TOKEN_ORDER_ESCROW_KEY]`, a BigSumTree with one SumTree + per token, keyed by order id. The token conservation check counts it beside the identity + balances and the shielded pools. +- The credits of an offer sit in `[PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`, + a SumTree keyed by order id, which the credit conservation check already counts. + diff --git a/package.json b/package.json index 215b16ec076..d42c6240a70 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:document-history-contract": "ultra -r --filter \"packages/@(document-history-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:app-connect-contract": "ultra -r --filter \"packages/@(app-connect-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:moderation-charters-contract": "ultra -r --filter \"packages/@(moderation-charters-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", + "test:token-market-contract": "ultra -r --filter \"packages/@(token-market-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:dapi-client": "ultra -r --filter \"packages/@(js-dapi-client|wallet-lib|js-dash-sdk|platform-test-suite)\" test", "test:sdk": "ultra -r --filter \"packages/@(js-dash-sdk|platform-test-suite)\" test", "test:spv": "ultra -r --filter \"packages/@(dash-spv|js-dapi-client)\" test", @@ -81,6 +82,7 @@ "packages/keyword-search-contract", "packages/app-connect-contract", "packages/moderation-charters-contract", + "packages/token-market-contract", "packages/wasm-drive-verify", "packages/wasm-sdk", "packages/js-evo-sdk" diff --git a/packages/data-contracts/Cargo.toml b/packages/data-contracts/Cargo.toml index a7562a2d2e2..1497637815d 100644 --- a/packages/data-contracts/Cargo.toml +++ b/packages/data-contracts/Cargo.toml @@ -20,6 +20,7 @@ all-contracts = [ "document-history", "app-connect", "moderation-charters", + "token-market", ] # Individual contract features @@ -33,6 +34,7 @@ keyword-search = ["dep:keyword-search-contract"] document-history = ["dep:document-history-contract"] app-connect = ["dep:app-connect-contract"] moderation-charters = ["dep:moderation-charters-contract"] +token-market = ["dep:token-market-contract"] [dependencies] thiserror = "2.0.12" @@ -49,6 +51,7 @@ keyword-search-contract = { path = "../keyword-search-contract", optional = true document-history-contract = { path = "../document-history-contract", optional = true } app-connect-contract = { path = "../app-connect-contract", optional = true } moderation-charters-contract = { path = "../moderation-charters-contract", optional = true } +token-market-contract = { path = "../token-market-contract", optional = true } [dev-dependencies] base58 = "0.2.0" diff --git a/packages/data-contracts/src/error.rs b/packages/data-contracts/src/error.rs index 698e7e727d1..e774af46cc3 100644 --- a/packages/data-contracts/src/error.rs +++ b/packages/data-contracts/src/error.rs @@ -203,3 +203,21 @@ impl From for Error { } } } + +#[cfg(feature = "token-market")] +impl From for Error { + fn from(e: token_market_contract::Error) -> Self { + match e { + token_market_contract::Error::UnknownVersionMismatch { + method, + known_versions, + received, + } => Error::UnknownVersionMismatch { + method, + known_versions, + received, + }, + token_market_contract::Error::InvalidSchemaJson(e) => Error::InvalidSchemaJson(e), + } + } +} diff --git a/packages/data-contracts/src/lib.rs b/packages/data-contracts/src/lib.rs index 850e0a93c50..0446a160952 100644 --- a/packages/data-contracts/src/lib.rs +++ b/packages/data-contracts/src/lib.rs @@ -31,6 +31,9 @@ pub use document_history_contract; #[cfg(feature = "token-history")] pub use token_history_contract; +#[cfg(feature = "token-market")] +pub use token_market_contract; + #[cfg(feature = "wallet-utils")] pub use wallet_utils_contract; @@ -57,6 +60,10 @@ pub enum SystemDataContract { /// protocol version 14 on: registered at genesis by chains born at 14 and inserted by the /// upgrade to 14. ModerationCharters = 10, + /// The open listings and offers of the token marketplace (protocol version 15). Registered + /// from protocol version 15 on: registered at genesis by chains born at 15 and inserted by + /// the upgrade to 15. + TokenMarket = 11, } pub struct DataContractSource { @@ -73,7 +80,7 @@ impl SystemDataContract { /// Deliberately kept beside the enum so that adding a variant and adding it here are the /// same edit. `assert_every_variant_is_listed` below makes that mechanical rather than /// remembered: a new variant makes its match non-exhaustive and the crate stops compiling. - pub const ALL: [SystemDataContract; 11] = [ + pub const ALL: [SystemDataContract; 12] = [ SystemDataContract::Withdrawals, SystemDataContract::MasternodeRewards, SystemDataContract::FeatureFlags, @@ -85,6 +92,7 @@ impl SystemDataContract { SystemDataContract::DocumentHistory, SystemDataContract::AppConnect, SystemDataContract::ModerationCharters, + SystemDataContract::TokenMarket, ]; /// A new variant must also be added to [`SystemDataContract::ALL`]; this match is where the @@ -178,6 +186,13 @@ impl SystemDataContract { 197, 6, 230, 72, 106, 198, 82, 129, 253, 135, 43, 86, 185, 182, 17, 112, 164, 127, 96, 5, 107, 185, 156, 46, 14, 10, 109, 237, 77, 228, 248, 129, ], + #[cfg(feature = "token-market")] + SystemDataContract::TokenMarket => token_market_contract::ID_BYTES, + #[cfg(not(feature = "token-market"))] + SystemDataContract::TokenMarket => [ + 137, 20, 125, 33, 9, 113, 160, 140, 158, 199, 2, 183, 75, 230, 71, 38, 213, 195, + 65, 13, 129, 32, 10, 91, 226, 116, 230, 164, 77, 101, 0, 147, + ], }; Identifier::new(bytes) } @@ -313,6 +328,16 @@ impl SystemDataContract { SystemDataContract::ModerationCharters => { Err(Error::ContractNotIncluded("moderation-charters")) } + #[cfg(feature = "token-market")] + SystemDataContract::TokenMarket => Ok(DataContractSource { + id_bytes: token_market_contract::ID_BYTES, + owner_id_bytes: token_market_contract::OWNER_ID_BYTES, + version: platform_version.system_data_contracts.token_market as u32, + definitions: token_market_contract::load_definitions(platform_version)?, + document_schemas: token_market_contract::load_documents_schemas(platform_version)?, + }), + #[cfg(not(feature = "token-market"))] + SystemDataContract::TokenMarket => Err(Error::ContractNotIncluded("token-market")), } } } @@ -365,6 +390,9 @@ mod tests { SystemDataContract::ModerationCharters => { published("EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88") } + SystemDataContract::TokenMarket => { + published("AE6yYq65VdfGCg2MSyV9vV7cWGiFWjbwBuy7THesDh4e") + } }; assert_eq!( diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index d9d074919af..a08770c3b2d 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -288,6 +288,7 @@ all-system_contracts = [ "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "token-market-contract", ] # Individual data contract features @@ -304,6 +305,7 @@ keywords-contract = ["data-contracts", "data-contracts/keyword-search"] document-history-contract = ["data-contracts", "data-contracts/document-history"] app-connect-contract = ["data-contracts", "data-contracts/app-connect"] moderation-charters-contract = ["data-contracts", "data-contracts/moderation-charters"] +token-market-contract = ["data-contracts", "data-contracts/token-market"] fixtures-and-mocks = ["all-system_contracts", "platform-value/json"] random-public-keys = ["bls-signatures", "ed25519-dalek"] random-identities = ["random-public-keys"] diff --git a/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs b/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs index 7f920242e54..36ca5ed1f4b 100644 --- a/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs +++ b/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs @@ -11,6 +11,9 @@ pub struct TotalTokensBalance { pub total_identity_token_balances: SumTokenAmount, /// all the tokens held in token shielded pools (0 before protocol version 14) pub total_token_shielded_pool_balances: SumTokenAmount, + /// all the tokens the token marketplace holds in escrow for open listings (0 before + /// protocol version 15) + pub total_token_order_escrow_balances: SumTokenAmount, } impl fmt::Display for TotalTokensBalance { @@ -28,9 +31,14 @@ impl fmt::Display for TotalTokensBalance { )?; writeln!( f, - " total_token_shielded_pool_balances: {}", + " total_token_shielded_pool_balances: {},", self.total_token_shielded_pool_balances )?; + writeln!( + f, + " total_token_order_escrow_balances: {}", + self.total_token_order_escrow_balances + )?; write!(f, "}}") } } @@ -42,6 +50,7 @@ impl TotalTokensBalance { total_tokens_in_platform, total_identity_token_balances, total_token_shielded_pool_balances, + total_token_order_escrow_balances, } = *self; if total_tokens_in_platform < 0 { @@ -62,26 +71,61 @@ impl TotalTokensBalance { )); } - let total_balances = total_identity_token_balances - .checked_add(total_token_shielded_pool_balances) - .ok_or_else(|| { - ProtocolError::CriticalCorruptedCreditsCodeExecution( - "Overflow adding identity and shielded pool token balances".to_string(), - ) - })?; + if total_token_order_escrow_balances < 0 { + return Err(ProtocolError::CriticalCorruptedCreditsCodeExecution( + "Tokens in order escrow are less than 0".to_string(), + )); + } - Ok(total_tokens_in_platform == total_balances) + Ok(total_tokens_in_platform == self.total_balances()?) } /// The balance side of the conservation equation: identity balances plus shielded pool - /// balances. Errors on overflow. + /// balances plus the tokens held in escrow for open listings. Errors on overflow. pub fn total_balances(&self) -> Result { self.total_identity_token_balances .checked_add(self.total_token_shielded_pool_balances) + .and_then(|balances| balances.checked_add(self.total_token_order_escrow_balances)) .ok_or_else(|| { ProtocolError::CriticalCorruptedCreditsCodeExecution( - "Overflow adding identity and shielded pool token balances".to_string(), + "Overflow adding identity, shielded pool and order escrow token balances" + .to_string(), ) }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_count_the_tokens_held_in_order_escrow_on_the_balance_side() { + let balance = TotalTokensBalance { + total_tokens_in_platform: 100, + total_identity_token_balances: 60, + total_token_shielded_pool_balances: 15, + total_token_order_escrow_balances: 25, + }; + assert!(balance.ok().expect("expected no overflow")); + assert_eq!(balance.total_balances().expect("expected no overflow"), 100); + + // Leaving the escrow out of the balance side is a broken conservation check. + let without_escrow = TotalTokensBalance { + total_token_order_escrow_balances: 0, + ..balance + }; + assert!(!without_escrow.ok().expect("expected no overflow")); + } + + #[test] + fn should_refuse_a_negative_order_escrow_total() { + let balance = TotalTokensBalance { + total_tokens_in_platform: 0, + total_identity_token_balances: 0, + total_token_shielded_pool_balances: 0, + total_token_order_escrow_balances: -1, + }; + assert!(balance.ok().is_err()); + } +} diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index c0a0f712f8c..c7e1a35c52d 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -79,6 +79,15 @@ impl ConfigurationForSystemContract for SystemDataContract { config.set_sized_integer_types_enabled(true); Ok(config) } + // Every integer of an order is a signed 8-byte value: the `depth` index of each + // order type sums one of them, which a summed property must be, and a partial fill + // rewrites `remaining` (and a bid's `escrow`) without changing the stored size of + // the order. + SystemDataContract::TokenMarket => { + let mut config = DataContractConfig::default_for_version(platform_version)?; + config.set_sized_integer_types_enabled(false); + Ok(config) + } } } } @@ -1379,3 +1388,73 @@ mod dpns_v3_tests { ); } } + +#[cfg(all(test, feature = "token-market-contract", feature = "validation"))] +mod token_market_tests { + use super::*; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::restricted_creation::CreationRestrictionMode; + use crate::data_contract::document_type::DocumentPropertyType; + use crate::system_data_contracts::token_market_contract::v1::document_types::{ask, bid}; + + /// The order types are what the marketplace transitions rely on: only they create orders, + /// orders stay mutable and deletable so a partial fill can rewrite them and a close can + /// delete them with their storage flags, every integer is a signed 8-byte value, and the + /// `depth` index sums what the order holds in escrow. + #[test] + fn should_load_the_order_types_the_marketplace_writes() { + let contract = + load_system_data_contract(SystemDataContract::TokenMarket, PlatformVersion::latest()) + .expect("the token market contract loads with full validation"); + assert_eq!(contract.id(), token_market_contract::ID); + assert_eq!(contract.owner_id(), token_market_contract::OWNER_ID); + assert_eq!(contract.document_types().len(), 2); + + for (name, summed) in [ + (ask::NAME, ask::properties::REMAINING), + (bid::NAME, bid::properties::ESCROW), + ] { + let document_type = contract + .document_type_for_name(name) + .unwrap_or_else(|_| panic!("the {name} type")); + assert!(document_type.documents_mutable(), "{name} is mutable"); + assert!( + document_type.documents_can_be_deleted(), + "{name} is deletable" + ); + assert_eq!( + document_type.creation_restriction_mode(), + CreationRestrictionMode::NoCreationAllowed, + "only the marketplace transitions create {name} orders" + ); + for property in document_type.properties().values() { + if matches!( + property.property_type, + DocumentPropertyType::U8 + | DocumentPropertyType::U16 + | DocumentPropertyType::U32 + | DocumentPropertyType::U64 + | DocumentPropertyType::I8 + | DocumentPropertyType::I16 + | DocumentPropertyType::I32 + ) { + panic!("{name} stores an integer narrower than i64 or unsigned"); + } + } + let depth = document_type + .indexes() + .get(ask::indexes::DEPTH) + .unwrap_or_else(|| panic!("{name} has a depth index")); + assert_eq!(depth.summable.as_deref(), Some(summed)); + } + } + + #[test] + fn should_not_exist_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + assert!( + load_system_data_contract(SystemDataContract::TokenMarket, platform_version).is_err() + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs index 127133b0a4a..d3f0b660867 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs @@ -6,6 +6,7 @@ use drive::dpp::identity::TimestampMillis; use dpp::block::block_info::BlockInfo; use dpp::prelude::CoreBlockHeight; use dpp::system_data_contracts::load_system_data_contract; +use dpp::version::feature_initial_protocol_versions::TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use drive::dpp::system_data_contracts::SystemDataContract; use drive::query::TransactionArg; @@ -88,6 +89,15 @@ impl Platform { ); } + // The token market contract activates with protocol version 15: a chain born at 15 + // registers it at genesis, an older chain gets it from `transition_to_version_15`. + if platform_version.protocol_version >= TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { + system_data_contract_types.insert( + SystemDataContract::TokenMarket, + system_data_contracts.load_token_market(platform_version)?, + ); + } + for data_contract in system_data_contract_types.values() { self.register_system_data_contract_operations( data_contract, @@ -261,5 +271,48 @@ mod tests { } } } + + /// The token market contract joins the genesis state at protocol version 15. + #[test] + pub fn should_register_the_token_market_contract_only_from_protocol_version_15() { + let token_market_id = SystemDataContract::TokenMarket.id(); + + for (platform_version, expected) in [ + (PlatformVersion::get(14).expect("protocol 14"), false), + (PlatformVersion::latest(), true), + ] { + let initial_protocol_version = platform_version.protocol_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(initial_protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let stored = platform + .drive + .fetch_contract( + token_market_id.to_buffer(), + None, + None, + None, + platform_version, + ) + .value + .expect("expected to query the token market contract"); + + assert_eq!( + stored.is_some(), + expected, + "token market contract presence in a genesis state born at protocol version {initial_protocol_version}" + ); + + if let Some(stored) = stored { + assert_eq!(stored.contract.id(), token_market_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert!(stored.contract.document_type_for_name("ask").is_ok()); + assert!(stored.contract.document_type_for_name("bid").is_ok()); + assert_eq!(stored.contract.document_types().len(), 2); + } + } + } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 25e190ec03a..29f22cffb7e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -910,7 +910,8 @@ impl Platform { /// When transitioning to version 15 we re-store the DPNS contract whose v3 /// schema checks a domain create with keywords instead of most of its data - /// trigger. + /// trigger, and add the token marketplace: its escrow trees and the token market + /// contract. fn transition_to_version_15( &self, block_info: &BlockInfo, @@ -930,6 +931,30 @@ impl Platform { platform_version, )?; + // Token marketplace escrow trees (v15 note 6): the BigSumTree under the Tokens tree of + // the tokens open listings hold, and the SumTree under the prefunded specialized + // balances of the credits open offers hold. The same helper builds them at genesis + // (`create_initial_state_structure_v5`), last, so both node populations build the same + // `[Tokens]` and `[PreFundedSpecializedBalances]` subtrees. + self.drive + .insert_token_order_escrow_trees(Some(transaction), platform_version)?; + + // Token market contract: the open listings and offers of the token marketplace, one + // system contract id on every network from this version. Fresh chains register it at + // genesis (`create_genesis_state` v1). Stored without storage flags, as genesis stores + // system contracts. + let token_market_contract = + load_system_data_contract(SystemDataContract::TokenMarket, platform_version)?; + + self.drive.apply_contract( + &token_market_contract, + *block_info, + true, + None, + Some(transaction), + platform_version, + )?; + Ok(()) } } @@ -3906,6 +3931,162 @@ mod tests { ); } + /// The token marketplace escrow trees are built by two paths that must coincide byte for + /// byte: `Drive::create_initial_state_structure_v5` at a v15 genesis and + /// `transition_to_version_15` on a chain upgraded from v14. + #[test] + fn test_genesis_v15_and_upgrade_to_v15_build_identical_token_order_escrow_trees() { + use drive::drive::prefunded_specialized_balances::PREFUNDED_BALANCES_FOR_TOKEN_ORDERS; + use drive::drive::tokens::paths::TOKEN_ORDER_ESCROW_KEY; + + let platform_version_15 = PlatformVersion::get(15).expect("expected v15"); + + let platform_a = TestPlatformBuilder::new() + .with_initial_protocol_version(15) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_b = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + + // A genuine v14 genesis has neither escrow tree yet. + { + let txn = platform_b.drive.grove.start_transaction(); + for (root, key) in [ + (RootTree::Tokens as u8, TOKEN_ORDER_ESCROW_KEY), + ( + RootTree::PreFundedSpecializedBalances as u8, + PREFUNDED_BALANCES_FOR_TOKEN_ORDERS, + ), + ] { + let root_path: [&[u8]; 1] = [&[root]]; + let escrow_root = platform_b.drive.grove.get( + SubtreePath::from(&root_path[..]), + &[key], + Some(&txn), + &platform_version_15.drive.grove_version, + ); + assert!( + escrow_root.value.is_err(), + "v14 genesis must not contain the escrow tree {key} under root {root}; got {:?}", + escrow_root.value + ); + } + } + + let txn_b = platform_b.drive.grove.start_transaction(); + platform_b + .transition_to_version_15(&BlockInfo::default(), &txn_b, platform_version_15) + .expect("upgrade: transition_to_version_15 should succeed"); + + let roots = [ + RootTree::Tokens as u8, + RootTree::PreFundedSpecializedBalances as u8, + ]; + let diffs = collect_subtree_diffs( + &platform_a, + &platform_b, + &txn_b, + roots.iter().map(|root| vec![*root]).collect(), + ); + assert!( + diffs.is_empty(), + "CONSENSUS FORK: the [Tokens] or [PreFundedSpecializedBalances] subtree differs \ + between a fresh genesis-v15 node and an in-place-upgraded v15 node.\n{}", + diffs.join("\n"), + ); + + // The children agree; the Merk of each root holds the new key itself, so compare the + // root elements too, which carry its root key and sum. + let root_element = |platform: &crate::platform_types::platform::Platform< + crate::rpc::core::MockCoreRPCLike, + >, + root: u8, + txn: drive::grovedb::TransactionArg| { + platform + .drive + .grove + .get_raw( + drive::grovedb_path::SubtreePath::empty(), + &[root], + txn, + &platform_version_15.drive.grove_version, + ) + .unwrap() + .expect("expected to read the root element") + }; + for root in roots { + assert_eq!( + root_element(&platform_a, root, None), + root_element(&platform_b, root, Some(&txn_b)), + "CONSENSUS FORK: the Merk of root {root} is shaped differently on a fresh \ + genesis-v15 node and an in-place-upgraded one" + ); + } + } + + #[test] + fn should_insert_the_token_market_contract_on_transition_to_version_15() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version_14 = PlatformVersion::get(14).expect("expected platform version 14"); + let platform_version = PlatformVersion::get(15).expect("expected platform version 15"); + let token_market_id = SystemDataContract::TokenMarket.id(); + + let transaction = platform.drive.grove.start_transaction(); + + assert!( + platform + .drive + .fetch_contract( + token_market_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version_14, + ) + .value + .expect("expected to query the token market contract") + .is_none(), + "the token market contract must not exist before transition_to_version_15" + ); + + platform + .transition_to_version_15(&BlockInfo::default(), &transaction, platform_version) + .expect("expected the transition to succeed"); + + let stored = platform + .drive + .fetch_contract( + token_market_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version, + ) + .value + .expect("expected to fetch the token market contract") + .expect("the token market contract must exist after transition_to_version_15"); + + assert_eq!(stored.contract.id(), token_market_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert_eq!(stored.contract.document_types().len(), 2); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(token_market_id, platform_version) + .expect("expected the post-activation lookup to succeed") + .is_some()); + } + /// CONSENSUS-CRITICAL equivalence guard for the v11→v12 boundary. /// /// The `[ShieldedBalances]` subtree is built two ways that MUST be diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs index e7e67702fd8..e80eed9163f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs @@ -5,6 +5,7 @@ use crate::execution::validation::state_transition::batch::data_triggers::bindin mod v0; mod v1; mod v2; +mod v3; pub fn data_trigger_bindings_list( platform_version: &PlatformVersion, @@ -29,9 +30,13 @@ pub fn data_trigger_bindings_list( .into_iter() .map(|binding| binding.into()) .collect()), + 3 => Ok(v3::data_trigger_bindings_list_v3()? + .into_iter() + .map(|binding| binding.into()) + .collect()), version => Err(ProtocolError::UnknownVersionMismatch { method: "data_trigger_bindings".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, }), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v3/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v3/mod.rs new file mode 100644 index 00000000000..d4d53e6885e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v3/mod.rs @@ -0,0 +1,66 @@ +use crate::execution::validation::state_transition::batch::data_triggers::bindings::data_trigger_binding::DataTriggerBindingV0; +use crate::execution::validation::state_transition::batch::data_triggers::bindings::list::v2::data_trigger_bindings_list_v2; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::reject::reject_data_trigger; + +use dpp::errors::ProtocolError; +use dpp::system_data_contracts::token_market_contract; +use dpp::system_data_contracts::token_market_contract::v1::document_types::{ask, bid}; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionActionType; + +/// Retrieves a list of data triggers binding with matching params. +/// +/// v3 (PROTOCOL_VERSION_15): v2 plus a reject of every replace and delete of a token market +/// contract order (`ask` and `bid`) sent as a document transition. Only the token marketplace +/// transitions write orders: creating one as a document is refused by +/// `creationRestrictionMode` 2, and the types stay mutable and deletable, so documents carry a +/// revision and storage flags for the partial fills and closes the platform writes. +#[inline(always)] +pub(super) fn data_trigger_bindings_list_v3() -> Result, ProtocolError> { + let mut data_triggers = data_trigger_bindings_list_v2()?; + for document_type in [ask::NAME, bid::NAME] { + for transition_action_type in [ + DocumentTransitionActionType::Replace, + DocumentTransitionActionType::Delete, + ] { + data_triggers.push(DataTriggerBindingV0 { + data_contract_id: token_market_contract::ID, + document_type: document_type.to_string(), + transition_action_type, + data_trigger: reject_data_trigger, + }); + } + } + Ok(data_triggers) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_reject_every_replace_and_delete_of_an_order_and_keep_the_v2_bindings() { + let v2 = data_trigger_bindings_list_v2().expect("expected the v2 bindings"); + let v3 = data_trigger_bindings_list_v3().expect("expected the v3 bindings"); + assert_eq!(v3.len(), v2.len() + 4); + + let token_market_bindings: Vec<(&str, DocumentTransitionActionType)> = v3 + .iter() + .filter(|binding| binding.data_contract_id == token_market_contract::ID) + .map(|binding| { + ( + binding.document_type.as_str(), + binding.transition_action_type, + ) + }) + .collect(); + assert_eq!( + token_market_bindings, + vec![ + (ask::NAME, DocumentTransitionActionType::Replace), + (ask::NAME, DocumentTransitionActionType::Delete), + (bid::NAME, DocumentTransitionActionType::Replace), + (bid::NAME, DocumentTransitionActionType::Delete), + ] + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 981acfa8853..08ae526ff9e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -17,8 +17,9 @@ mod deletion_tests { // increases byte-billed contract-tree reads. // The app-connect and moderation charters contracts each add one sibling to the // genesis contracts tree, increasing the bytes billed when reading that tree - // (protocol 14 only). - 1817440, + // (protocol 14 only). The token market contract (protocol 15) adds another, which + // reshapes the tree so the contract read is billed fewer bytes (-47_280). + 1770160, ) .await; } 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 b13506a59a0..21b367464e6 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 @@ -36,8 +36,10 @@ mod dpns_tests { // The app-connect contract adds one sibling to the genesis contracts tree, // increasing the bytes billed when reading that tree (protocol 14 only). The // moderation charters contract adds another, which reshapes the tree so the - // DPNS contract's node is billed fewer bytes to reach (-287_760). - 5_761_600, + // DPNS contract's node is billed fewer bytes to reach (-287_760). The token market + // contract (protocol 15) adds a third, and the DPNS node is billed more bytes to + // reach again (+137_760). + 5_899_360, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index fcf943c447d..4399e288be9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -1570,8 +1570,10 @@ mod dpns_v3_tests { vec![ (1_159_720, 31_158_000, 0), (1_846_280, 42_687_000, 0), - (5_495_080, 11_558_570, 0), - (3_654_380, 42_687_000, 0) + // At 15 the upgrade also inserts the token market contract, a sibling in the + // contracts tree that makes the DPNS node cost more bytes to reach (+95_920). + (5_591_000, 11_558_570, 0), + (3_750_300, 42_687_000, 0) ] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index e8e126d7000..20e537337e5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -525,8 +525,9 @@ mod replacement_tests { // increases byte-billed contract-tree reads. // The app-connect and moderation charters contracts each add one sibling to the // genesis contracts tree, increasing the bytes billed when reading that tree - // (protocol 14 only). - 1546140, + // (protocol 14 only). The token market contract (protocol 15) adds another, which + // reshapes the tree so the contract read is billed fewer bytes (-47_280). + 1498860, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index a637f1d36c2..da346f5d286 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "2b58577a1cdb38e9da1f3fe0b16556f15cd11f851364bf8e404dfb4f14b4410a" + "d4d3e450ba03232065a1444d56c3bbad58948715d8226d7ac37ae2596544b7b0" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "4ba6e38cdbdda59c48644b8e0df3182a805bd87a7b3584906df8a84008c7df36", + "10584c4856a0a7897c2258c2c51d6f4141b5eeb38ebaccf90b5a14ef493cd154", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "1eb8b109ddb6a2d8896b113387132673e42f18f9abe560a607607593517ee3a3", + "dab93d242f9e364877a51944dafddde83a7584cf2e99eb4076994d21c506d9dc", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "9f22a694033eb8c899df1fbad5c34bc97710ebad14aa2aff2dc495024bb6685b", + "148b96fd9a5a577c6b1a1e59e8bf629db3379b14c5c8ab6c92268ece85f1f06c", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "4a7e0f9b38b87219f276b6e54a4c0ace51245c23201057a0846be726f6854089", + "442cdee8f9c256c642306cab8abfd53362e3c1f38c5529587999ba0bf318764e", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "63be3fcbb363b244ab581de6cbaf38e50fd042bf8c276745ef62c2b5031abd53", + "ddcd591c01930437263911b1282a83011e1327873c8bf8486df75db141c50f72", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "637c59bb64c308336fb7de597fb34bb3137a39f9b45996b2ebbf238e7d7372ec", + "8faa85cda86ea6ec3cd189d629cc218e5b73976114cbbcd092f16fa4f578879b", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "0c0789e5dc329c11b67d202552957cd81cc4ca78feee6c019d1e6e519bcd7439", + "e5531fb8b5b7f937aa33f0eb499db2516cdcbd00c59f898edf9d36b272287df7", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "9485f54bfbbe8c73408d48186132b9f1fc8e5fc37685712bda30bc6478d29cdc", + "136802e28210b47624836d21ed7fcfd3cea8d7dd37400977081363a3b1429776", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "24fe37d9d5e2a81c90c7415007d6351f87aa253c051dcc75c473a4b69b33d71e", + "16ee120ee53d4b8eccb11419bfd128550d01a471803b508a809e0da6f5f81a44", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 6d99f41c026..c92d552074a 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -781,6 +781,69 @@ } ] }, + { + "id": "tokens.order_escrow", + "key": { + "type": "fixed", + "hex": "60", + "label": "TokenOrderEscrow", + "constant": "TOKEN_ORDER_ESCROW_KEY" + }, + "kinds": [ + "BigSumTree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/tokens/paths.rs", + "book": "data-model/token-marketplace.md", + "description": "The tokens open listings hold in escrow. The sum joins the identity balances and the shielded pools in the token conservation check.", + "children": [ + { + "id": "tokens.order_escrow.token", + "key": { + "type": "dynamic", + "name": "token_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The token id" + }, + "kinds": [ + "SumTree" + ], + "since": 15, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/tokens/paths.rs", + "description": "The escrowed listings of one token; created with its first listing.", + "children": [ + { + "id": "tokens.order_escrow.token.order", + "key": { + "type": "dynamic", + "name": "order_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The listing's order id" + }, + "kinds": [ + "SumItem" + ], + "value": "token amount", + "since": 15, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/tokens/paths.rs", + "description": "What one open listing still holds.", + "children": [] + } + ] + } + ] + }, { "id": "tokens.balances", "key": { @@ -1970,6 +2033,47 @@ "source": "packages/rs-drive/src/drive/mod.rs", "description": "Credits put aside in advance to fund specific state transitions, such as masternode votes on a contested resource.", "children": [ + { + "id": "prefunded_balances.for_token_orders", + "key": { + "type": "fixed", + "hex": "20", + "label": "ForTokenOrders", + "constant": "PREFUNDED_BALANCES_FOR_TOKEN_ORDERS" + }, + "kinds": [ + "SumTree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs", + "book": "data-model/token-marketplace.md", + "description": "The credits the token marketplace holds in escrow for open offers.", + "children": [ + { + "id": "prefunded_balances.for_token_orders.offer", + "key": { + "type": "dynamic", + "name": "order_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The offer's order id" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 15, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs", + "description": "What one open offer still holds.", + "children": [] + } + ] + }, { "id": "prefunded_balances.owner_fee_pots", "key": { @@ -6046,7 +6150,10 @@ "tree": { "hex": "80", "left": { - "hex": "40" + "hex": "40", + "left": { + "hex": "20" + } }, "right": { "hex": "c0" @@ -6150,7 +6257,10 @@ "hex": "20" }, "right": { - "hex": "5c" + "hex": "5c", + "right": { + "hex": "60" + } } }, "right": { diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index ada5710ee55..56492cb1f66 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -6,6 +6,7 @@ use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use platform_version::version::feature_initial_protocol_versions::{ APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION, + TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, }; use platform_version::version::{PlatformVersion, ProtocolVersion}; use std::collections::BTreeMap; @@ -179,6 +180,15 @@ impl SystemDataContracts { self.load(SystemDataContract::ModerationCharters, platform_version) } + /// Returns the token market contract materialized for `platform_version`: the open listings + /// and offers of the token marketplace (protocol version 15). + pub fn load_token_market( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.load(SystemDataContract::TokenMarket, platform_version) + } + /// Returns the system contract whose deterministic identifier matches `id`, materialized /// for `platform_version`. /// @@ -223,6 +233,8 @@ impl SystemDataContracts { SystemDataContract::ModerationCharters => { MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION } + // Written to state by the transition to protocol version 15. + SystemDataContract::TokenMarket => TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, // Never served from this cache: `WalletUtils` is only ever read from grovedb, and // the reserved `FeatureFlags` slot has no implementation. SystemDataContract::WalletUtils | SystemDataContract::FeatureFlags => return Ok(None), @@ -452,6 +464,27 @@ mod tests { .is_none()); } + #[test] + fn should_serve_token_market_only_from_its_activation_version() { + let contracts = SystemDataContracts::new(); + + assert!(contracts + .find_by_id(SystemDataContract::TokenMarket.id(), platform_version(14)) + .expect("expected the pre-activation lookup to succeed") + .is_none()); + assert!(contracts + .find_by_id( + SystemDataContract::TokenMarket.id(), + platform_version(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) + ) + .expect("expected the v15 lookup to succeed") + .is_some()); + assert!(contracts + .find_by_id(SystemDataContract::TokenMarket.id(), platform_version(14)) + .expect("an old-version lookup after materialization must succeed") + .is_none()); + } + #[test] fn should_serve_app_connect_only_from_its_activation_version() { let contracts = SystemDataContracts::new(); diff --git a/packages/rs-drive/src/drive/initialization/mod.rs b/packages/rs-drive/src/drive/initialization/mod.rs index ce157e315c0..5db1db9fdb7 100644 --- a/packages/rs-drive/src/drive/initialization/mod.rs +++ b/packages/rs-drive/src/drive/initialization/mod.rs @@ -6,6 +6,7 @@ mod v1; mod v2; mod v3; mod v4; +mod v5; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -32,9 +33,10 @@ impl Drive { 2 => self.create_initial_state_structure_v2(transaction, platform_version), 3 => self.create_initial_state_structure_v3(transaction, platform_version), 4 => self.create_initial_state_structure_v4(transaction, platform_version), + 5 => self.create_initial_state_structure_v5(transaction, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "create_initial_state_structure".to_string(), - known_versions: vec![0, 1, 2, 3, 4], + known_versions: vec![0, 1, 2, 3, 4, 5], received: version, })), } diff --git a/packages/rs-drive/src/drive/initialization/v5/mod.rs b/packages/rs-drive/src/drive/initialization/v5/mod.rs new file mode 100644 index 00000000000..858f42f8894 --- /dev/null +++ b/packages/rs-drive/src/drive/initialization/v5/mod.rs @@ -0,0 +1,29 @@ +//! Drive Initialization +//! +//! Version 5: version 4 plus the token marketplace escrow trees (protocol version 15). + +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Creates the initial state structure. + pub(super) fn create_initial_state_structure_v5( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.create_initial_state_structure_v4(transaction, platform_version)?; + + // Token marketplace escrow trees (protocol version 15): the BigSumTree under the Tokens + // tree of the tokens open listings hold, and the SumTree under the prefunded specialized + // balances of the credits open offers hold. Last, after everything version 4 builds, and + // through the same helper as the upgrade path (`Platform::transition_to_version_15`), so + // a chain born at version 15 and one upgraded to it build byte-identical `[Tokens]` and + // `[PreFundedSpecializedBalances]` subtrees. + self.insert_token_order_escrow_trees(transaction, platform_version)?; + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs index 393d63e7138..055d96c0f3f 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs @@ -27,6 +27,11 @@ use crate::util::batch::GroveDbOpBatch; /// The key for prefunded balances for voting pub const PREFUNDED_BALANCES_FOR_VOTING: u8 = 128; +/// The key for the credits the token marketplace holds in escrow for open offers (protocol +/// version 15): a SumTree whose SumItems are keyed by order id. It sits under the prefunded +/// specialized balances root, a SumTree, so the credit conservation check counts it. +pub const PREFUNDED_BALANCES_FOR_TOKEN_ORDERS: u8 = 32; + /// prefunded specialized balances for voting pub fn prefunded_specialized_balances_path() -> [&'static [u8]; 1] { [Into::<&[u8; 1]>::into( @@ -34,6 +39,22 @@ pub fn prefunded_specialized_balances_path() -> [&'static [u8]; 1] { )] } +/// The credits held in escrow for the token marketplace's open offers +pub fn prefunded_specialized_balances_for_token_orders_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances), + &[PREFUNDED_BALANCES_FOR_TOKEN_ORDERS], + ] +} + +/// The credits held in escrow for the token marketplace's open offers, as a vec +pub fn prefunded_specialized_balances_for_token_orders_path_vec() -> Vec> { + vec![ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances).to_vec(), + vec![PREFUNDED_BALANCES_FOR_TOKEN_ORDERS], + ] +} + /// prefunded specialized balances vector pub fn prefunded_specialized_balances_path_vec() -> Vec> { vec![Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances).to_vec()] diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs index 6c0ac23e716..d34de62cd7b 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs @@ -1,7 +1,9 @@ use crate::drive::contract::paths::{ PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES, PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES, }; -use crate::drive::prefunded_specialized_balances::PREFUNDED_BALANCES_FOR_VOTING; +use crate::drive::prefunded_specialized_balances::{ + PREFUNDED_BALANCES_FOR_TOKEN_ORDERS, PREFUNDED_BALANCES_FOR_VOTING, +}; use crate::drive::RootTree; use crate::structure::{ElementKind, StructureNode}; @@ -53,6 +55,29 @@ pub(crate) fn structure() -> StructureNode { until the contract owner claims them.", "What the contract's owner can claim, at most once per epoch.", )) + .child( + StructureNode::fixed( + "for_token_orders", + &[PREFUNDED_BALANCES_FOR_TOKEN_ORDERS], + "ForTokenOrders", + "PREFUNDED_BALANCES_FOR_TOKEN_ORDERS", + ) + .kind(ElementKind::SumTree) + .since(15) + .source("packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs") + .book("data-model/token-marketplace.md") + .describe( + "The credits the token marketplace holds in escrow \ + for open offers.", + ) + .child( + StructureNode::identifier("offer", "order_id", "The offer's order id") + .kind(ElementKind::SumItem) + .lazy() + .value("credits") + .describe("What one open offer still holds."), + ), + ) .child(contract_fee_pots( "moderators_fee_pots", PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES, diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/mod.rs index 7818b611a8d..2203b2c24e5 100644 --- a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/mod.rs +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -39,9 +40,10 @@ impl Drive { { 0 => self.calculate_total_tokens_balance_v0(transaction, platform_version), 1 => self.calculate_total_tokens_balance_v1(transaction, platform_version), + 2 => self.calculate_total_tokens_balance_v2(transaction, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "calculate_total_tokens_balance".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs index 6e723e81c4e..a0285e39a58 100644 --- a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs @@ -44,6 +44,7 @@ impl Drive { total_identity_token_balances, // Token shielded pools do not exist before protocol version 14. total_token_shielded_pool_balances: 0, + total_token_order_escrow_balances: 0, }) } } diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs index ddd56681587..c74dff226d8 100644 --- a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs @@ -53,6 +53,8 @@ impl Drive { total_tokens_in_platform, total_identity_token_balances, total_token_shielded_pool_balances, + // v1 predates the token order escrow (protocol version 15). + total_token_order_escrow_balances: 0, }) } } diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v2/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v2/mod.rs new file mode 100644 index 00000000000..c4e69d0f301 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v2/mod.rs @@ -0,0 +1,71 @@ +use crate::drive::balances::TOTAL_TOKEN_SUPPLIES_STORAGE_KEY; +use crate::drive::system::misc_path; +use crate::drive::tokens::paths::{ + tokens_root_path, TOKEN_BALANCES_KEY, TOKEN_ORDER_ESCROW_KEY, TOKEN_SHIELDED_POOLS_KEY, +}; +use crate::drive::Drive; +use crate::error::Error; +use crate::util::grove_operations::DirectQueryType; +use dpp::balances::total_tokens_balance::TotalTokensBalance; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Version 2 (protocol version 15): the token marketplace escrow BigSumTree joins the + /// identity balances and the shielded pools on the balance side, so `identity balances + + /// shielded pool balances + escrowed listings` must equal the total supplies. Listing a token + /// moves it from its holder's balance into escrow without touching supply. + #[inline(always)] + pub(super) fn calculate_total_tokens_balance_v2( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut drive_operations = vec![]; + let path_holding_total_credits = misc_path(); + let total_tokens_in_platform = self.grove_get_big_sum_tree_total_value( + (&path_holding_total_credits).into(), + TOTAL_TOKEN_SUPPLIES_STORAGE_KEY, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + let tokens_root_path = tokens_root_path(); + + let total_identity_token_balances = self.grove_get_big_sum_tree_total_value( + (&tokens_root_path).into(), + &[TOKEN_BALANCES_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + let total_token_shielded_pool_balances = self.grove_get_big_sum_tree_total_value( + (&tokens_root_path).into(), + &[TOKEN_SHIELDED_POOLS_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + let total_token_order_escrow_balances = self.grove_get_big_sum_tree_total_value( + (&tokens_root_path).into(), + &[TOKEN_ORDER_ESCROW_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + Ok(TotalTokensBalance { + total_tokens_in_platform, + total_identity_token_balances, + total_token_shielded_pool_balances, + total_token_order_escrow_balances, + }) + } +} diff --git a/packages/rs-drive/src/drive/tokens/mod.rs b/packages/rs-drive/src/drive/tokens/mod.rs index 72e9be5897b..e5127d9461a 100644 --- a/packages/rs-drive/src/drive/tokens/mod.rs +++ b/packages/rs-drive/src/drive/tokens/mod.rs @@ -55,6 +55,11 @@ pub mod unfreeze; #[cfg(feature = "server")] pub mod calculate_total_tokens_balance; +/// The escrow of the token marketplace (protocol version 15): the tokens open listings hold and +/// the credits open offers hold. +#[cfg(feature = "server")] +pub mod order_escrow; + mod contract_info; mod direct_purchase; /// Distribution module diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs new file mode 100644 index 00000000000..a189f5ebcb4 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs @@ -0,0 +1,137 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// The operations that hold `credits` in escrow for the new offer `order_id`. The caller + /// removes the credits from the buyer's balance in the same batch: the escrow sits under the + /// prefunded specialized balances, which the credit conservation check counts. + pub fn add_to_credit_order_escrow_operations( + &self, + order_id: [u8; 32], + credits: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .add_credits + { + 0 => self.add_to_credit_order_escrow_operations_v0( + order_id, + credits, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_to_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations that set what the open offer `order_id` holds in escrow to `credits`, + /// after a partial fill. An offer filled entirely or cancelled is removed instead. + pub fn set_credit_order_escrow_operations( + &self, + order_id: [u8; 32], + credits: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .set_credits + { + 0 => self.set_credit_order_escrow_operations_v0( + order_id, + credits, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "set_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations that remove the escrow entry of the offer `order_id`, when it is filled + /// entirely or cancelled. + pub fn remove_credit_order_escrow_operations( + &self, + order_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .remove_credits + { + 0 => self.remove_credit_order_escrow_operations_v0( + order_id, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// What the offer `order_id` holds in escrow, `None` when it holds nothing. + pub fn fetch_credit_order_escrow( + &self, + order_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .fetch_credits + { + 0 => self.fetch_credit_order_escrow_v0( + order_id, + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_credit_order_escrow".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/credits/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/credits/v0/mod.rs new file mode 100644 index 00000000000..f82845ac292 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/credits/v0/mod.rs @@ -0,0 +1,127 @@ +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_for_token_orders_path, + prefunded_specialized_balances_for_token_orders_path_vec, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +/// Escrowed credits are a SumItem, which holds an i64. +fn escrow_sum_value(credits: Credits) -> Result { + i64::try_from(credits).map_err(|_| { + ProtocolError::CriticalCorruptedCreditsCodeExecution(format!( + "credits to hold in escrow over i64 max, are {credits}" + )) + .into() + }) +} + +impl Drive { + pub(super) fn add_to_credit_order_escrow_operations_v0( + &self, + order_id: [u8; 32], + credits: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_credit_order_escrow( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + Ok(vec![ + LowLevelDriveOperation::insert_for_known_path_key_element( + prefunded_specialized_balances_for_token_orders_path_vec(), + order_id.to_vec(), + Element::new_sum_item(escrow_sum_value(credits)?), + ), + ]) + } + + pub(super) fn set_credit_order_escrow_operations_v0( + &self, + order_id: [u8; 32], + credits: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_credit_order_escrow( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + Ok(vec![ + LowLevelDriveOperation::replace_for_known_path_key_element( + prefunded_specialized_balances_for_token_orders_path_vec(), + order_id.to_vec(), + Element::new_sum_item(escrow_sum_value(credits)?), + ), + ]) + } + + pub(super) fn remove_credit_order_escrow_operations_v0( + &self, + order_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_credit_order_escrow( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + Ok(vec![GroveOperation( + QualifiedGroveDbOp::delete_op( + prefunded_specialized_balances_for_token_orders_path_vec(), + order_id.to_vec(), + ) + .dont_check_for_backwards_references(), + )]) + } + + pub(super) fn fetch_credit_order_escrow_v0( + &self, + order_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = prefunded_specialized_balances_for_token_orders_path(); + let element = self.grove_get_raw_optional( + (&path).into(), + order_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )?; + match element { + None => Ok(None), + Some(Element::SumItem(credits, _)) if credits >= 0 => Ok(Some(credits as Credits)), + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "a credit order escrow entry must be a non-negative sum item", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs new file mode 100644 index 00000000000..39ec5bfb4c2 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs @@ -0,0 +1,65 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds the estimated layers of a write to one listing's escrowed tokens. + pub(crate) fn add_estimation_costs_for_token_order_escrow( + token_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .estimated_costs_for_tokens + { + 0 => { + Self::add_estimation_costs_for_token_order_escrow_v0( + token_id, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_token_order_escrow".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Adds the estimated layers of a write to one offer's escrowed credits. + pub(crate) fn add_estimation_costs_for_credit_order_escrow( + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .estimated_costs_for_credits + { + 0 => { + Self::add_estimation_costs_for_credit_order_escrow_v0( + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_credit_order_escrow".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs new file mode 100644 index 00000000000..1af40a9660b --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs @@ -0,0 +1,112 @@ +use crate::drive::constants::AVERAGE_BALANCE_SIZE; +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_for_token_orders_path_vec, prefunded_specialized_balances_path, +}; +use crate::drive::tokens::paths::{ + token_order_escrow_path, token_order_escrow_root_path, tokens_root_path, +}; +use crate::drive::Drive; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::{AllBigSumTrees, AllSumTrees, NoSumTrees, SomeSumTrees}; +use grovedb::{EstimatedLayerInformation, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// The layers a write to one listing's escrow entry goes through: the root, the Tokens + /// tree, where the escrow BigSumTree is a leaf on the third level, the escrow BigSumTree of + /// one SumTree per token, and the token's SumTree of one entry per open listing. + pub(super) fn add_estimation_costs_for_token_order_escrow_v0( + token_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(2, false), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(tokens_root_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(token_order_escrow_root_path()), + EstimatedLayerInformation { + tree_type: TreeType::BigSumTree, + estimated_layer_count: EstimatedLevel(10, false), + estimated_layer_sizes: AllSubtrees(DEFAULT_HASH_SIZE_U8, AllSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(token_order_escrow_path(&token_id)), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, AVERAGE_BALANCE_SIZE, None), + }, + ); + } + + /// The layers a write to one offer's escrowed credits goes through: the root, the prefunded + /// specialized balances, described as the prefunded balance and contract fee pot estimations + /// describe it so a batch writing to several of its trees gets one answer, and the SumTree of + /// one entry per open offer. + pub(super) fn add_estimation_costs_for_credit_order_escrow_v0( + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_sizes: AllSubtrees( + 1, + SomeSumTrees { + sum_trees_weight: 1, + big_sum_trees_weight: 0, + count_trees_weight: 0, + count_sum_trees_weight: 0, + non_sum_trees_weight: 1, + provable_sum_trees_weight: 0, + provable_count_trees_weight: 0, + provable_count_sum_trees_weight: 0, + provable_count_provable_sum_trees_weight: 0, + }, + None, + ), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(prefunded_specialized_balances_path()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: EstimatedLevel(1, false), + estimated_layer_sizes: AllSubtrees(1, AllSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path( + prefunded_specialized_balances_for_token_orders_path_vec(), + ), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, AVERAGE_BALANCE_SIZE, None), + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/insert_trees.rs b/packages/rs-drive/src/drive/tokens/order_escrow/insert_trees.rs new file mode 100644 index 00000000000..c8d17cb7a45 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/insert_trees.rs @@ -0,0 +1,53 @@ +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_path, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS, +}; +use crate::drive::tokens::paths::{tokens_root_path, TOKEN_ORDER_ESCROW_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; +use grovedb_path::SubtreePath; + +impl Drive { + /// Inserts the two escrow trees of the token marketplace (protocol version 15) if they do not + /// exist yet: + /// + /// - `[Tokens, TOKEN_ORDER_ESCROW_KEY]`, an empty BigSumTree that holds one SumTree per + /// token, keyed by order id, of the tokens its open listings hold in escrow. It is counted + /// on the balance side of the token conservation check + /// (`calculate_total_tokens_balance` 2). + /// - `[PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`, an empty SumTree, + /// keyed by order id, of the credits the open offers hold in escrow. The prefunded + /// specialized balances root is a SumTree, so the credit conservation check counts it + /// without a change. + /// + /// CONSENSUS-CRITICAL: both the genesis path (`Drive::create_initial_state_structure_v5`) + /// and the upgrade path (`Platform::transition_to_version_15`) call this one helper, in the + /// same position, so a chain born at protocol version 15 and a chain upgraded to it build + /// byte-identical `[Tokens]` and `[PreFundedSpecializedBalances]` subtrees. + pub fn insert_token_order_escrow_trees( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let tokens_root = tokens_root_path(); + self.grove_insert_if_not_exists( + SubtreePath::from(&tokens_root), + &[TOKEN_ORDER_ESCROW_KEY], + Element::empty_big_sum_tree(), + transaction, + None, + &platform_version.drive, + )?; + let prefunded_root = prefunded_specialized_balances_path(); + self.grove_insert_if_not_exists( + SubtreePath::from(&prefunded_root), + &[PREFUNDED_BALANCES_FOR_TOKEN_ORDERS], + Element::empty_sum_tree(), + transaction, + None, + &platform_version.drive, + )?; + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs new file mode 100644 index 00000000000..a7bebc2b6b7 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -0,0 +1,211 @@ +//! The escrow of the token marketplace (protocol version 15). +//! +//! An open listing holds the tokens it offers in `[Tokens, TOKEN_ORDER_ESCROW_KEY, token id, +//! order id]`, a SumItem of the token's escrow SumTree, and an open offer holds the credits it +//! offers in `[PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS, order id]`. Both +//! roots are inserted by `insert_token_order_escrow_trees`. + +mod credits; +mod estimated_costs; +mod insert_trees; +mod tokens; + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::fees::op::LowLevelDriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + fn apply(drive: &Drive, operations: Vec) { + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to apply the operations"); + } + + #[test] + fn should_hold_set_and_release_what_a_listing_escrows() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let order_id = [9; 32]; + let fetch = |drive: &Drive, token_id: [u8; 32]| { + drive + .fetch_token_order_escrow(token_id, order_id, None, &mut vec![], platform_version) + .expect("expected to fetch the escrow") + }; + + // A token that never had a listing has no escrow tree, and nothing in escrow. + assert_eq!(fetch(&drive, token_id), None); + + apply( + &drive, + drive + .add_to_token_order_escrow_operations( + token_id, + order_id, + 500, + &mut None, + None, + platform_version, + ) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive, token_id), Some(500)); + assert_eq!(fetch(&drive, [6; 32]), None); + + apply( + &drive, + drive + .set_token_order_escrow_operations( + token_id, + order_id, + 200, + &mut None, + platform_version, + ) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive, token_id), Some(200)); + + apply( + &drive, + drive + .remove_token_order_escrow_operations( + token_id, + order_id, + &mut None, + platform_version, + ) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive, token_id), None); + } + + #[test] + fn should_hold_set_and_release_what_an_offer_escrows() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let order_id = [9; 32]; + let fetch = |drive: &Drive| { + drive + .fetch_credit_order_escrow(order_id, None, &mut vec![], platform_version) + .expect("expected to fetch the escrow") + }; + + assert_eq!(fetch(&drive), None); + apply( + &drive, + drive + .add_to_credit_order_escrow_operations( + order_id, + 70_000, + &mut None, + platform_version, + ) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive), Some(70_000)); + apply( + &drive, + drive + .set_credit_order_escrow_operations(order_id, 30_000, &mut None, platform_version) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive), Some(30_000)); + apply( + &drive, + drive + .remove_credit_order_escrow_operations(order_id, &mut None, platform_version) + .expect("expected the escrow operations"), + ); + assert_eq!(fetch(&drive), None); + } + + /// Listing moves tokens from a balance into escrow: the supply is unchanged, and the token + /// conservation check of protocol version 15 counts the escrow. The calculator of protocol + /// version 14, which reads no escrow, would see tokens missing. + #[test] + fn should_keep_the_token_supply_balanced_when_tokens_move_into_escrow() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let identity_id = [6; 32]; + let block_info = BlockInfo::default(); + + drive + .create_token_trees( + Identifier::from([4; 32]), + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create the token trees"); + drive + .token_mint( + token_id, + identity_id, + 1000, + true, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to mint"); + + let mut operations = drive + .remove_from_identity_token_balance_operations( + token_id, + identity_id, + 400, + &mut None, + None, + platform_version, + ) + .expect("expected the balance operations"); + operations.extend( + drive + .add_to_token_order_escrow_operations( + token_id, + [9; 32], + 400, + &mut None, + None, + platform_version, + ) + .expect("expected the escrow operations"), + ); + apply(&drive, operations); + + let total = drive + .calculate_total_tokens_balance(None, platform_version) + .expect("expected to calculate the total tokens balance"); + assert_eq!(total.total_tokens_in_platform, 1000); + assert_eq!(total.total_identity_token_balances, 600); + assert_eq!(total.total_token_order_escrow_balances, 400); + assert!(total.ok().expect("expected no overflow")); + + let before_the_marketplace = drive + .calculate_total_tokens_balance( + None, + PlatformVersion::get(14).expect("expected protocol version 14"), + ) + .expect("expected to calculate the total tokens balance"); + assert!(!before_the_marketplace.ok().expect("expected no overflow")); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs new file mode 100644 index 00000000000..520d369a06d --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs @@ -0,0 +1,137 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::balances::credits::TokenAmount; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// The operations that hold `amount` of the token `token_id` in escrow for the new listing + /// `order_id`, creating the token's escrow tree with its first listing. The caller removes + /// the amount from the seller's balance in the same batch: escrow moves tokens, it does not + /// create them, and the token conservation check counts the escrow. + pub fn add_to_token_order_escrow_operations( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + amount: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.token.order_escrow.add_tokens { + 0 => self.add_to_token_order_escrow_operations_v0( + token_id, + order_id, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_to_token_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations that set what the open listing `order_id` of `token_id` holds in escrow to + /// `amount`, after a partial fill. `amount` is above zero: a listing filled entirely is + /// removed instead. + pub fn set_token_order_escrow_operations( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + amount: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.token.order_escrow.set_tokens { + 0 => self.set_token_order_escrow_operations_v0( + token_id, + order_id, + amount, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "set_token_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations that remove the escrow entry of the listing `order_id` of `token_id`, + /// when it is filled entirely or cancelled. The token's escrow tree stays. + pub fn remove_token_order_escrow_operations( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .remove_tokens + { + 0 => self.remove_token_order_escrow_operations_v0( + token_id, + order_id, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_token_order_escrow_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// What the listing `order_id` of `token_id` holds in escrow, `None` when it holds nothing. + pub fn fetch_token_order_escrow( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .fetch_tokens + { + 0 => self.fetch_token_order_escrow_v0( + token_id, + order_id, + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_token_order_escrow".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs new file mode 100644 index 00000000000..564abab13ec --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs @@ -0,0 +1,155 @@ +use crate::drive::tokens::paths::{ + token_order_escrow_path, token_order_escrow_path_vec, token_order_escrow_root_path, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::{BatchInsertTreeApplyType, DirectQueryType}; +use crate::util::object_size_info::PathKeyInfo::PathFixedSizeKeyRef; +use dpp::balances::credits::TokenAmount; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +/// An escrowed amount is a SumItem, which holds an i64. +fn escrow_sum_value(amount: TokenAmount) -> Result { + i64::try_from(amount).map_err(|_| { + ProtocolError::CriticalCorruptedCreditsCodeExecution(format!( + "token amount to hold in escrow over i64 max, is {amount}" + )) + .into() + }) +} + +impl Drive { + pub(super) fn add_to_token_order_escrow_operations_v0( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + amount: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_token_order_escrow( + token_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + let tree_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::BigSumTree, + tree_type: TreeType::SumTree, + flags_len: 0, + } + }; + + // The token's escrow tree is created with its first listing. + self.batch_insert_empty_tree_if_not_exists( + PathFixedSizeKeyRef::<2>((token_order_escrow_root_path(), token_id.as_slice())), + TreeType::SumTree, + None, + tree_apply_type, + transaction, + &mut None, + &mut drive_operations, + &platform_version.drive, + )?; + + drive_operations.push(LowLevelDriveOperation::insert_for_known_path_key_element( + token_order_escrow_path_vec(token_id), + order_id.to_vec(), + Element::new_sum_item(escrow_sum_value(amount)?), + )); + + Ok(drive_operations) + } + + pub(super) fn set_token_order_escrow_operations_v0( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + amount: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_token_order_escrow( + token_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + Ok(vec![ + LowLevelDriveOperation::replace_for_known_path_key_element( + token_order_escrow_path_vec(token_id), + order_id.to_vec(), + Element::new_sum_item(escrow_sum_value(amount)?), + ), + ]) + } + + pub(super) fn remove_token_order_escrow_operations_v0( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_token_order_escrow( + token_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + Ok(vec![GroveOperation( + QualifiedGroveDbOp::delete_op(token_order_escrow_path_vec(token_id), order_id.to_vec()) + .dont_check_for_backwards_references(), + )]) + } + + pub(super) fn fetch_token_order_escrow_v0( + &self, + token_id: [u8; 32], + order_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = token_order_escrow_path(&token_id); + let element = self.grove_get_raw_optional( + (&path).into(), + order_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )?; + match element { + None => Ok(None), + Some(Element::SumItem(amount, _)) if amount >= 0 => Ok(Some(amount as TokenAmount)), + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "a token order escrow entry must be a non-negative sum item", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/paths.rs b/packages/rs-drive/src/drive/tokens/paths.rs index 69fc5945300..63037812436 100644 --- a/packages/rs-drive/src/drive/tokens/paths.rs +++ b/packages/rs-drive/src/drive/tokens/paths.rs @@ -30,6 +30,14 @@ pub const TOKEN_DISTRIBUTIONS_KEY: u8 = 32; /// balance SumItem. The BigSumTree total is the amount of every token currently shielded and /// joins the identity balances on the balance side of the token conservation check. pub const TOKEN_SHIELDED_POOLS_KEY: u8 = 224; +/// Key for the token order escrow tree (a BigSumTree of per-token SumTrees). +/// +/// Introduced in protocol version 15 for the token marketplace. Each token that has had an open +/// listing owns one child SumTree keyed by its token id, created with its first listing, whose +/// SumItems, keyed by order id, are the tokens each open listing holds in escrow. The BigSumTree +/// total is every token held in escrow and joins the identity balances and the shielded pools on +/// the balance side of the token conservation check (`calculate_total_tokens_balance` 2). +pub const TOKEN_ORDER_ESCROW_KEY: u8 = 96; // The Token Merk tree looks like // TOKEN_BALANCES_KEY @@ -37,6 +45,9 @@ pub const TOKEN_SHIELDED_POOLS_KEY: u8 = 224; // TOKEN_STATUS_INFO_KEY TOKEN_IDENTITY_INFO_KEY // / \ / // TOKEN_DISTRIBUTIONS_KEY TOKEN_DIRECT_SELL_PRICE_KEY TOKEN_CONTRACT_INFO_KEY +// +// TOKEN_SHIELDED_POOLS_KEY (protocol version 14) is the right child of TOKEN_IDENTITY_INFO_KEY, +// and TOKEN_ORDER_ESCROW_KEY (protocol version 15) the right child of TOKEN_DIRECT_SELL_PRICE_KEY. // The token distribution Tree level @@ -696,6 +707,37 @@ pub fn token_shielded_pools_root_path_vec() -> Vec> { vec![vec![RootTree::Tokens as u8], vec![TOKEN_SHIELDED_POOLS_KEY]] } +/// The root path of the token order escrow tree +pub fn token_order_escrow_root_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::Tokens), + &[TOKEN_ORDER_ESCROW_KEY], + ] +} + +/// The root path of the token order escrow tree as a vec +pub fn token_order_escrow_root_path_vec() -> Vec> { + vec![vec![RootTree::Tokens as u8], vec![TOKEN_ORDER_ESCROW_KEY]] +} + +/// The path of one token's escrow tree, whose SumItems are keyed by order id +pub fn token_order_escrow_path(token_id: &[u8; 32]) -> [&[u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Tokens), + &[TOKEN_ORDER_ESCROW_KEY], + token_id, + ] +} + +/// The path of one token's escrow tree as a vec +pub fn token_order_escrow_path_vec(token_id: [u8; 32]) -> Vec> { + vec![ + vec![RootTree::Tokens as u8], + vec![TOKEN_ORDER_ESCROW_KEY], + token_id.to_vec(), + ] +} + #[cfg(feature = "server")] #[cfg(test)] mod tests { diff --git a/packages/rs-drive/src/drive/tokens/structure.rs b/packages/rs-drive/src/drive/tokens/structure.rs index 6dec7165950..435e2b87491 100644 --- a/packages/rs-drive/src/drive/tokens/structure.rs +++ b/packages/rs-drive/src/drive/tokens/structure.rs @@ -6,7 +6,7 @@ use crate::drive::tokens::paths::{ TOKEN_BALANCES_KEY, TOKEN_BLOCK_TIMED_DISTRIBUTIONS_KEY, TOKEN_CONTRACT_INFO_KEY, TOKEN_DIRECT_SELL_PRICE_KEY, TOKEN_DISTRIBUTIONS_KEY, TOKEN_EPOCH_TIMED_DISTRIBUTIONS_KEY, TOKEN_IDENTITY_INFO_KEY, TOKEN_MS_TIMED_DISTRIBUTIONS_KEY, - TOKEN_ONCE_PER_IDENTITY_DISTRIBUTIONS_KEY, + TOKEN_ONCE_PER_IDENTITY_DISTRIBUTIONS_KEY, TOKEN_ORDER_ESCROW_KEY, TOKEN_PERPETUAL_DISTRIBUTIONS_FOR_IDENTITIES_LAST_CLAIM_KEY, TOKEN_PERPETUAL_DISTRIBUTIONS_INFO_KEY, TOKEN_PERPETUAL_DISTRIBUTIONS_KEY, TOKEN_PRE_PROGRAMMED_DISTRIBUTIONS_FOR_IDENTITIES_LAST_CLAIM_KEY, @@ -44,6 +44,7 @@ pub(crate) fn structure() -> StructureNode { .children(vec![ distributions(), shielded_pools(), + order_escrow(), StructureNode::fixed( "status", &[TOKEN_STATUS_INFO_KEY], @@ -372,6 +373,37 @@ fn distributions() -> StructureNode { ]) } +/// The tokens the token marketplace holds in escrow for open listings, from protocol 15. +fn order_escrow() -> StructureNode { + StructureNode::fixed( + "order_escrow", + &[TOKEN_ORDER_ESCROW_KEY], + "TokenOrderEscrow", + "TOKEN_ORDER_ESCROW_KEY", + ) + .kind(ElementKind::BigSumTree) + .since(15) + .source(SOURCE) + .book("data-model/token-marketplace.md") + .describe( + "The tokens open listings hold in escrow. The sum \ + joins the identity balances and the shielded pools \ + in the token conservation check.", + ) + .child( + token("The escrowed listings of one token; created with its first listing.") + .kind(ElementKind::SumTree) + .lazy() + .child( + StructureNode::identifier("order", "order_id", "The listing's order id") + .kind(ElementKind::SumItem) + .lazy() + .value("token amount") + .describe("What one open listing still holds."), + ), + ) +} + /// One Orchard pool per token that enables shielded balances in protocol 14. fn shielded_pools() -> StructureNode { StructureNode::fixed( diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 46cc279415e..6791241903e 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1750,6 +1750,42 @@ mod fixtures { .expect("expected to commit the token anchor"); conformance_of(&drive, "token_shielded_pool", run); } + /// The token marketplace escrow: two listings of one token holding tokens, and two offers + /// holding credits. + fn token_order_escrow(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [7; 32]; + for (order_id, amount) in [([1; 32], 500), ([2; 32], 30)] { + apply_operations( + &drive, + drive + .add_to_token_order_escrow_operations( + token_id, + order_id, + amount, + &mut None, + None, + platform_version, + ) + .expect("expected the listing escrow operations"), + ); + } + for (order_id, credits) in [([3; 32], 70_000), ([4; 32], 1_000)] { + apply_operations( + &drive, + drive + .add_to_credit_order_escrow_operations( + order_id, + credits, + &mut None, + platform_version, + ) + .expect("expected the offer escrow operations"), + ); + } + conformance_of(&drive, "token_order_escrow", run); + } /// Documents of a type declaring a `ttl`: stored without storage flags, each with an entry /// in the documents expirations tree under the time it expires, and a lifetime storage fee /// pool. Two of them expire together, created in the same block. @@ -1863,6 +1899,7 @@ mod fixtures { contract_groups_and_bound_keys(&mut run); spent_nullifiers(&mut run); token_shielded_pool(&mut run); + token_order_escrow(&mut run); run } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 7d0733c37e3..7e34c173092 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -15,6 +15,9 @@ use super::{ /// - `create_domain_data_trigger` 2, which keeps only the parent domain checks of a DPNS domain /// create: the DPNS contract v3 (`SYSTEM_DATA_CONTRACT_VERSIONS_V4`) checks the normalized /// names, the preorder and the identity record with schema keywords. +/// - the data trigger `bindings` 3, which refuse a document transition's replace or delete of a +/// token market contract order (`ask` and `bid`): only the token marketplace transitions write +/// them. /// - `token_config_update_transition_state_validation` 2, which also checks the marketplace rules /// of the configuration an update proposes: a token can be made tradeable only if it is /// transferable. @@ -32,6 +35,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { token_config_update_transition_state_validation: 2, // a tradeable token must be transferable data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { + bindings: 3, // the token market contract's orders are written by the marketplace transitions only triggers: DriveAbciValidationDataTriggerVersions { create_domain_data_trigger: 2, ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 @@ -40,10 +44,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = .data_triggers .triggers }, - ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 - .state_transitions - .batch_state_transition - .data_triggers }, ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 .state_transitions diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index a44447be92a..0f8b135b4d2 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveTokenMethodVersions { @@ -10,6 +11,23 @@ pub struct DriveTokenMethodVersions { pub update: DriveTokenUpdateMethodVersions, pub calculate_total_tokens_balance: FeatureVersion, pub distribution: DriveTokenDistributionMethodVersions, + /// The escrow of the token marketplace (protocol version 15): the tokens open listings hold + /// and the credits open offers hold. + pub order_escrow: DriveTokenOrderEscrowMethodVersions, +} + +#[derive(Clone, Debug, Default)] +pub struct DriveTokenOrderEscrowMethodVersions { + pub add_tokens: FeatureVersion, + pub set_tokens: FeatureVersion, + pub remove_tokens: FeatureVersion, + pub fetch_tokens: FeatureVersion, + pub add_credits: FeatureVersion, + pub set_credits: FeatureVersion, + pub remove_credits: FeatureVersion, + pub fetch_credits: FeatureVersion, + pub estimated_costs_for_tokens: FeatureVersion, + pub estimated_costs_for_credits: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs index aa16f2f4b37..1318dfa25e4 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs @@ -1,6 +1,7 @@ use crate::version::drive_versions::drive_token_method_versions::{ DriveTokenDistributionMethodVersions, DriveTokenFetchMethodVersions, DriveTokenMethodVersions, - DriveTokenProveMethodVersions, DriveTokenUpdateMethodVersions, + DriveTokenOrderEscrowMethodVersions, DriveTokenProveMethodVersions, + DriveTokenUpdateMethodVersions, }; pub const DRIVE_TOKEN_METHOD_VERSIONS_V1: DriveTokenMethodVersions = DriveTokenMethodVersions { @@ -69,4 +70,18 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V1: DriveTokenMethodVersions = DriveTokenM mark_once_per_identity_release_as_distributed: 0, evonode_participation_rewards: 0, }, + // The token marketplace (protocol version 15) is refused before its activation, so these + // methods are never reached by an older protocol version. + order_escrow: DriveTokenOrderEscrowMethodVersions { + add_tokens: 0, + set_tokens: 0, + remove_tokens: 0, + fetch_tokens: 0, + add_credits: 0, + set_credits: 0, + remove_credits: 0, + fetch_credits: 0, + estimated_costs_for_tokens: 0, + estimated_costs_for_credits: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs new file mode 100644 index 00000000000..3b749789e20 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs @@ -0,0 +1,13 @@ +use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_token_method_versions::DriveTokenMethodVersions; + +/// Drive token methods for protocol v15+. +/// +/// Identical to [`super::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2`] except +/// `calculate_total_tokens_balance` is bumped to `2`: the tokens the token marketplace holds in +/// escrow for open listings join the identity balances and the shielded pools on the balance +/// side of the token conservation check. +pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenMethodVersions { + calculate_total_tokens_balance: 2, + ..DRIVE_TOKEN_METHOD_VERSIONS_V2 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/mod.rs index 1d7c32caed6..889baec9f92 100644 --- a/packages/rs-platform-version/src/version/drive_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/mod.rs @@ -30,6 +30,7 @@ pub mod drive_token_method_versions; pub mod drive_verify_method_versions; pub mod drive_vote_method_versions; pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs new file mode 100644 index 00000000000..09e432b4e62 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -0,0 +1,29 @@ +use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; +use crate::version::drive_versions::v9::DRIVE_VERSION_V9; +use crate::version::drive_versions::{ + DriveInitializationMethodVersions, DriveMethodVersions, DriveVersion, +}; + +/// Drive version 10. +/// Introduced in protocol v15 for the token marketplace: +/// +/// * **Escrow trees** — `create_initial_state_structure` 5 adds the two escrow trees of the +/// token marketplace: `[Tokens, TOKEN_ORDER_ESCROW_KEY]`, the BigSumTree of the tokens open +/// listings hold, and `[PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`, +/// the SumTree of the credits open offers hold. The upgrade path +/// (`Platform::transition_to_version_15`) inserts them through the same helper. +/// * **Token conservation** — `DRIVE_TOKEN_METHOD_VERSIONS_V3` bumps +/// `calculate_total_tokens_balance` to 2, which counts the escrowed listings on the balance +/// side. +/// +/// Everything else matches `DRIVE_VERSION_V9`. +pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { + methods: DriveMethodVersions { + initialization: DriveInitializationMethodVersions { + create_initial_state_structure: 5, // changed in v10: adds the token marketplace escrow trees + }, + token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow + ..DRIVE_VERSION_V9.methods + }, + ..DRIVE_VERSION_V9 +}; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs index 1fe2f9352a6..236fb641817 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs @@ -17,4 +17,7 @@ pub struct SystemDataContractVersions { pub document_history: FeatureVersion, pub app_connect: FeatureVersion, pub moderation_charters: FeatureVersion, + /// The token market contract (protocol version 15): the open listings and offers of the + /// token marketplace. 0 before it exists. + pub token_market: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs index c800f384b2c..6579be07a4c 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs @@ -16,4 +16,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V1: SystemDataContractVersions = // The moderation charters contract does not exist before protocol version 14 // either: no schema generation is selected here. moderation_charters: 0, + token_market: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs index 8dc349d7ad1..c801347ee48 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs @@ -21,4 +21,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V2: SystemDataContractVersions = // The moderation charters contract does not exist before protocol version 14 // either: no schema generation is selected here. moderation_charters: 0, + token_market: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index 5bdeac3b0b4..dcaa7d4843d 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -33,4 +33,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = document_history: 1, app_connect: 1, moderation_charters: 1, + token_market: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs index 63fdcd01b8e..d2d08aae446 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs @@ -5,8 +5,10 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // reveal and deletion of the writer's own preorder from an earlier block // (preorders unsalted, one per owner and name, the first valid one of a name // chosen), and the new domain's identity record held to its owner. Domains can -// no longer be deleted. v3 (dpns: 2) remains for PROTOCOL_VERSION_14 chain -// replay. +// no longer be deleted. The token market contract (token_market: 1), the open +// listings and offers of the token marketplace, is written to state by the +// upgrade to 15 and registered at genesis from 15 on. v3 (dpns: 2, no token +// market) remains for PROTOCOL_VERSION_14 chain replay. pub const SYSTEM_DATA_CONTRACT_VERSIONS_V4: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, @@ -19,4 +21,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V4: SystemDataContractVersions = document_history: 1, app_connect: 1, moderation_charters: 1, + token_market: 1, }; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index c71af01508c..dbe8d3fd120 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -21,7 +21,7 @@ use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIV use crate::version::drive_abci_versions::drive_abci_validation_versions::v11::DRIVE_ABCI_VALIDATION_VERSIONS_V11; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; use crate::version::drive_abci_versions::DriveAbciVersion; -use crate::version::drive_versions::v9::DRIVE_VERSION_V9; +use crate::version::drive_versions::v10::DRIVE_VERSION_V10; use crate::version::fee::v3::FEE_VERSION3; use crate::version::protocol_version::PlatformVersion; use crate::version::system_data_contract_versions::v4::SYSTEM_DATA_CONTRACT_VERSIONS_V4; @@ -136,9 +136,22 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `token_config_update_transition_state_validation` 2 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the configuration an update proposes, charged. /// Protocol v14 and earlier keep `token_config_update_transition_state_validation` 1. +/// +/// 6. **The token marketplace's state**: `DRIVE_VERSION_V10` creates the two escrow trees at +/// genesis (`create_initial_state_structure` 5) and `transition_to_version_15` inserts them +/// through the same helper: `[Tokens, TOKEN_ORDER_ESCROW_KEY]`, a BigSumTree of one SumTree +/// per token of the tokens its open listings hold, and `[PreFundedSpecializedBalances, +/// PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`, a SumTree of the credits open offers hold. Its +/// `calculate_total_tokens_balance` 2 counts the escrowed tokens on the balance side of the +/// token conservation check; the credit check already counts every prefunded specialized +/// balance. `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects the token market contract v1, whose +/// `ask` and `bid` types hold the open orders: registered at genesis from this version and +/// stored without storage flags by `transition_to_version_15`. Users cannot write them: +/// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V9, + drive: DRIVE_VERSION_V10, // changed: the token marketplace escrow trees and calculate_total_tokens_balance 2 drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 33f50995ad7..a9122bdbb89 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -26,6 +26,7 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "token-market-contract", ] } simple-signer = { path = "../simple-signer" } async-trait = { version = "0.1.83" } diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index 2b297c19b39..e3ac51fd5da 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -46,6 +46,7 @@ all-system-contracts = [ "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "token-market-contract", ] # Individual contract features - these enable specific contracts in DPP @@ -58,6 +59,7 @@ keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] app-connect-contract = ["dpp/app-connect-contract"] moderation-charters-contract = ["dpp/moderation-charters-contract"] +token-market-contract = ["dpp/token-market-contract"] [target.'cfg(target_arch = "wasm32")'.dependencies] # Runs the browser's non-`Send` quorum fetches and timers on the local executor. diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 9e68e6ebf8a..1ce49a2d96a 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -24,6 +24,7 @@ use dpp::data_contract::TokenConfiguration; feature = "document-history-contract", feature = "app-connect-contract", feature = "moderation-charters-contract", + feature = "token-market-contract", feature = "all-system-contracts" ))] use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -34,6 +35,8 @@ use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIA feature = "all-system-contracts" ))] use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; +#[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] +use dpp::version::feature_initial_protocol_versions::TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use futures::future::{BoxFuture, FutureExt}; @@ -1050,6 +1053,7 @@ impl ContextProvider for TrustedHttpContextProvider { feature = "document-history-contract", feature = "app-connect-contract", feature = "moderation-charters-contract", + feature = "token-market-contract", feature = "all-system-contracts" ))] { @@ -1194,6 +1198,24 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] + // Below protocol version 15 the token market contract is absent too. + if *id == SystemDataContract::TokenMarket.id() + && platform_version.protocol_version >= TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION + { + return load_system_data_contract( + SystemDataContract::TokenMarket, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load TokenMarket contract: {}", + e + )) + }); + } } // If not found in known contracts or system contracts, delegate to fallback provider if available @@ -2518,6 +2540,34 @@ mod tests { assert_eq!(contract.id(), id); } + /// The token market system contract is served only from its activation version on. + #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] + #[test] + fn should_serve_token_market_only_from_protocol_15() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::version::PlatformVersion; + + // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let id = SystemDataContract::TokenMarket.id(); + + assert!(provider + .get_data_contract(&id, PlatformVersion::get(14).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + + let contract = provider + .get_data_contract(&id, PlatformVersion::get(15).unwrap()) + .expect("the lookup must succeed at protocol version 15") + .expect("the token market contract must be served at protocol version 15"); + assert_eq!(contract.id(), id); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost) diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 64f0486d29e..7b1795f367b 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -152,6 +152,7 @@ keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] app-connect-contract = ["dpp/app-connect-contract"] moderation-charters-contract = ["dpp/moderation-charters-contract"] +token-market-contract = ["dpp/token-market-contract"] token_reward_explanations = ["dpp/token-reward-explanations"] diff --git a/packages/token-market-contract/.mocharc.yml b/packages/token-market-contract/.mocharc.yml new file mode 100644 index 00000000000..164b941c1b6 --- /dev/null +++ b/packages/token-market-contract/.mocharc.yml @@ -0,0 +1,2 @@ +require: test/bootstrap.js +recursive: true diff --git a/packages/token-market-contract/Cargo.toml b/packages/token-market-contract/Cargo.toml new file mode 100644 index 00000000000..c338081d99c --- /dev/null +++ b/packages/token-market-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "token-market-contract" +description = "Token market data contract schema and tools. The open orders of the token marketplace" +version.workspace = true +edition = "2021" +rust-version.workspace = true +license = "MIT" + +[dependencies] +thiserror = "2.0.12" +platform-version = { path = "../rs-platform-version" } +serde_json = { version = "1.0" } +platform-value = { path = "../rs-platform-value" } +[dev-dependencies] +base58 = "0.2.0" diff --git a/packages/token-market-contract/LICENSE b/packages/token-market-contract/LICENSE new file mode 100644 index 00000000000..3be95833750 --- /dev/null +++ b/packages/token-market-contract/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2019 Dash Core Group, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md new file mode 100644 index 00000000000..59e6472e47c --- /dev/null +++ b/packages/token-market-contract/README.md @@ -0,0 +1,61 @@ +# Token Market Contract + +The token market system contract holds the open orders of the token +marketplace: the listings (`ask`) holders make to sell a tradeable token at a +price, and the offers (`bid`) anyone makes to buy one. It activates at protocol +version 15, registered at genesis by chains born at 15 and inserted by the +upgrade to 15, and has the same ID on every network: +`AE6yYq65VdfGCg2MSyV9vV7cWGiFWjbwBuy7THesDh4e`. + +Only the token marketplace transitions write these documents. Users cannot +create them (`creationRestrictionMode` 2), and a replace or delete sent as a +document transition is refused. The order's owner is its maker, who pays the +order's storage and gets it back when the order closes. What the order holds +in escrow (the listed tokens of an ask, the offered credits of a bid) is kept +by Platform under the order's id, outside this contract. + +## `ask` + +Tokens a holder offers for sale at a price. + +| Property | Type | Meaning | +| --- | --- | --- | +| `tokenId` | identifier, required | The token for sale | +| `price` | integer, required | The credits the seller asks per whole token (10 to the power of the token's decimals base units) | +| `amount` | integer, required | The base units listed when the order was placed | +| `remaining` | integer, required | The base units still for sale; the order is deleted when it is filled entirely | +| `minFill` | integer, required | The smallest fill the seller accepts, unless it takes everything still open; a fill may not leave less than this open either | +| `expiresAt` | integer | The block time, in milliseconds, from which the order can no longer be filled; it can still be cancelled | + +## `bid` + +An offer to buy a token at a price: the same properties as an `ask`, read from +the buyer's side, and `escrow`. + +| Property | Type | Meaning | +| --- | --- | --- | +| `escrow` | integer, required | The credits still held in escrow for the order; what is left once it is filled goes back to the buyer | + +## Indexes + +Both types have the same three: + +- `book` (`tokenId`, `price`, `$createdAt`): the order book of a token, by + price and then by age. +- `depth` (`tokenId`, `price`): keeps a sum per price, and over any range of + prices, of what is open: the base units of the asks (`remaining`) and the + credits of the bids (`escrow`). Each side sums what it holds in escrow, which + the token's supply bounds for asks and the credits on Platform bound for bids, + so the sums cannot overflow. +- `byOwner` (`$ownerId`, `tokenId`, `$createdAt`): the open orders of an + identity. + +## Install + +```sh +npm install @dashevo/token-market-contract +``` + +## License + +[MIT](LICENSE) © Dash Core Group, Inc. diff --git a/packages/token-market-contract/eslint.config.mjs b/packages/token-market-contract/eslint.config.mjs new file mode 100644 index 00000000000..cdf50e57d0d --- /dev/null +++ b/packages/token-market-contract/eslint.config.mjs @@ -0,0 +1,10 @@ +import baseConfig from '../../eslint/base.mjs'; +import mochaTestConfig from '../../eslint/mocha-tests.mjs'; + +export default [ + ...baseConfig, + mochaTestConfig, + { + ignores: ['dist/**', 'node_modules/**'], + }, +]; diff --git a/packages/token-market-contract/lib/systemIds.js b/packages/token-market-contract/lib/systemIds.js new file mode 100644 index 00000000000..14788ab902f --- /dev/null +++ b/packages/token-market-contract/lib/systemIds.js @@ -0,0 +1,4 @@ +module.exports = { + ownerId: '11111111111111111111111111111111', + contractId: 'AE6yYq65VdfGCg2MSyV9vV7cWGiFWjbwBuy7THesDh4e', +}; diff --git a/packages/token-market-contract/package.json b/packages/token-market-contract/package.json new file mode 100644 index 00000000000..a44ba6206f2 --- /dev/null +++ b/packages/token-market-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/token-market-contract", + "version": "5.0.0-rc.4", + "description": "A system contract for the open orders of the token marketplace", + "scripts": { + "lint": "eslint .", + "test": "yarn run test:unit", + "test:unit": "mocha 'test/unit/**/*.spec.js'" + }, + "contributors": [ + { + "name": "Samuel Westrich", + "email": "sam@dash.org", + "url": "https://github.com/quantumexplorer" + } + ], + "license": "MIT", + "devDependencies": { + "@dashevo/wasm-dpp": "workspace:*", + "chai": "^4.3.10", + "dirty-chai": "^2.0.1", + "eslint": "^9.18.0", + "mocha": "^11.1.0", + "sinon": "^18.0.1", + "sinon-chai": "^3.7.0" + } +} diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json new file mode 100644 index 00000000000..96c77ef66ea --- /dev/null +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -0,0 +1,209 @@ +{ + "ask": { + "type": "object", + "description": "Tokens a holder offers for sale at a price. Written only by the token marketplace transitions: the listed tokens are held in escrow under the order's id until it is filled or cancelled.", + "creationRestrictionMode": 2, + "indices": [ + { + "name": "book", + "properties": [ + { + "tokenId": "asc" + }, + { + "price": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "depth", + "properties": [ + { + "tokenId": "asc" + }, + { + "price": "asc" + } + ], + "summable": "remaining", + "rangeSummable": true + }, + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + }, + { + "tokenId": "asc" + }, + { + "$createdAt": "asc" + } + ] + } + ], + "properties": { + "tokenId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "The token the order is for", + "position": 0 + }, + "price": { + "type": "integer", + "minimum": 1, + "description": "The credits the seller asks per whole token (10 to the power of the token's decimals base units)", + "position": 1 + }, + "amount": { + "type": "integer", + "minimum": 1, + "description": "The token amount, in base units, listed when the order was placed", + "position": 2 + }, + "remaining": { + "type": "integer", + "minimum": 1, + "description": "The token amount, in base units, still open. The order is deleted when it is filled entirely", + "position": 3 + }, + "minFill": { + "type": "integer", + "minimum": 1, + "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", + "position": 4 + }, + "expiresAt": { + "type": "integer", + "minimum": 0, + "description": "The block time, in milliseconds, from which the order can no longer be filled. It can still be cancelled", + "position": 5 + } + }, + "required": [ + "tokenId", + "price", + "amount", + "remaining", + "minFill", + "$createdAt", + "$updatedAt", + "$createdAtBlockHeight" + ], + "additionalProperties": false + }, + "bid": { + "type": "object", + "description": "An offer to buy a token at a price. Written only by the token marketplace transitions: the offered credits are held in escrow under the order's id until it is filled or cancelled.", + "creationRestrictionMode": 2, + "indices": [ + { + "name": "book", + "properties": [ + { + "tokenId": "asc" + }, + { + "price": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "depth", + "properties": [ + { + "tokenId": "asc" + }, + { + "price": "asc" + } + ], + "summable": "escrow", + "rangeSummable": true + }, + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + }, + { + "tokenId": "asc" + }, + { + "$createdAt": "asc" + } + ] + } + ], + "properties": { + "tokenId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "The token the order is for", + "position": 0 + }, + "price": { + "type": "integer", + "minimum": 1, + "description": "The credits the buyer pays per whole token (10 to the power of the token's decimals base units)", + "position": 1 + }, + "amount": { + "type": "integer", + "minimum": 1, + "description": "The token amount, in base units, wanted when the order was placed", + "position": 2 + }, + "remaining": { + "type": "integer", + "minimum": 1, + "description": "The token amount, in base units, still open. The order is deleted when it is filled entirely", + "position": 3 + }, + "minFill": { + "type": "integer", + "minimum": 1, + "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", + "position": 4 + }, + "expiresAt": { + "type": "integer", + "minimum": 0, + "description": "The block time, in milliseconds, from which the order can no longer be filled. It can still be cancelled", + "position": 5 + }, + "escrow": { + "type": "integer", + "minimum": 0, + "description": "The credits still held in escrow for the order. They cover what the open amount costs at the order's price; what is left once it is filled goes back to the buyer", + "position": 6 + } + }, + "required": [ + "tokenId", + "price", + "amount", + "remaining", + "minFill", + "escrow", + "$createdAt", + "$updatedAt", + "$createdAtBlockHeight" + ], + "additionalProperties": false + } +} diff --git a/packages/token-market-contract/src/error.rs b/packages/token-market-contract/src/error.rs new file mode 100644 index 00000000000..d01bbcc91cf --- /dev/null +++ b/packages/token-market-contract/src/error.rs @@ -0,0 +1,17 @@ +use platform_version::version::FeatureVersion; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Platform expected some specific versions + #[error("platform unknown version on {method}, received: {received}")] + UnknownVersionMismatch { + /// method + method: String, + /// the allowed versions for this method + known_versions: Vec, + /// requested core height + received: FeatureVersion, + }, + #[error("schema deserialize error: {0}")] + InvalidSchemaJson(#[from] serde_json::Error), +} diff --git a/packages/token-market-contract/src/lib.rs b/packages/token-market-contract/src/lib.rs new file mode 100644 index 00000000000..d1034537a9c --- /dev/null +++ b/packages/token-market-contract/src/lib.rs @@ -0,0 +1,143 @@ +mod error; +pub mod v1; + +pub use crate::error::Error; +use platform_value::{Identifier, IdentifierBytes32}; +use platform_version::version::PlatformVersion; +use serde_json::Value; + +pub const ID_BYTES: [u8; 32] = [ + 137, 20, 125, 33, 9, 113, 160, 140, 158, 199, 2, 183, 75, 230, 71, 38, 213, 195, 65, 13, 129, + 32, 10, 91, 226, 116, 230, 164, 77, 101, 0, 147, +]; + +pub const OWNER_ID_BYTES: [u8; 32] = [0; 32]; + +pub const ID: Identifier = Identifier(IdentifierBytes32(ID_BYTES)); +pub const OWNER_ID: Identifier = Identifier(IdentifierBytes32(OWNER_ID_BYTES)); + +/// The contract's definitions for `platform_version`. Version 0, the value the tables of +/// protocol versions below 15 carry, names no schema generation and is refused like any +/// unknown version: the contract does not exist before its activation. +pub fn load_definitions(platform_version: &PlatformVersion) -> Result, Error> { + match platform_version.system_data_contracts.token_market { + 1 => Ok(None), + version => Err(Error::UnknownVersionMismatch { + method: "token_market_contract::load_definitions".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +pub fn load_documents_schemas(platform_version: &PlatformVersion) -> Result { + match platform_version.system_data_contracts.token_market { + 1 => v1::load_documents_schemas(), + version => Err(Error::UnknownVersionMismatch { + method: "token_market_contract::load_documents_schemas".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +#[cfg(test)] +mod tests { + use base58::FromBase58; + + use super::*; + + #[test] + fn should_match_the_published_system_contract_id() { + let base58_decoded = "AE6yYq65VdfGCg2MSyV9vV7cWGiFWjbwBuy7THesDh4e" + .from_base58() + .expect("expected a base58 id"); + assert_eq!(base58_decoded, ID_BYTES); + } + + #[test] + fn should_load_the_schema_at_the_latest_platform_version() { + use v1::document_types::{ask, bid}; + + let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); + let declared = |name: &str, properties: &[&str], summed: &str| { + let document_type = schema + .get(name) + .unwrap_or_else(|| panic!("the {name} document type is declared")); + let declared_properties = document_type + .get("properties") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("{name} has properties")); + for property in properties { + assert!( + declared_properties.contains_key(*property), + "{name} declares {property}" + ); + } + let indexes = document_type + .get("indices") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{name} has indexes")); + let declared_indexes: Vec<&str> = indexes + .iter() + .filter_map(|index| index.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!( + declared_indexes, + [ + ask::indexes::BOOK, + ask::indexes::DEPTH, + ask::indexes::BY_OWNER + ], + "the indexes of {name}" + ); + assert_eq!( + indexes[1].get("summable").and_then(Value::as_str), + Some(summed), + "the depth index of {name} sums {summed}" + ); + // Only the platform writes orders. + assert_eq!(document_type["creationRestrictionMode"], 2); + }; + + declared( + ask::NAME, + &[ + ask::properties::TOKEN_ID, + ask::properties::PRICE, + ask::properties::AMOUNT, + ask::properties::REMAINING, + ask::properties::MIN_FILL, + ask::properties::EXPIRES_AT, + ], + ask::properties::REMAINING, + ); + declared( + bid::NAME, + &[ + bid::properties::TOKEN_ID, + bid::properties::PRICE, + bid::properties::AMOUNT, + bid::properties::REMAINING, + bid::properties::MIN_FILL, + bid::properties::EXPIRES_AT, + bid::properties::ESCROW, + ], + bid::properties::ESCROW, + ); + assert_eq!(schema.as_object().map(|types| types.len()), Some(2)); + } + + #[test] + fn should_refuse_to_load_below_the_activation_version() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + assert!(matches!( + load_documents_schemas(platform_version), + Err(Error::UnknownVersionMismatch { received: 0, .. }) + )); + assert!(matches!( + load_definitions(platform_version), + Err(Error::UnknownVersionMismatch { received: 0, .. }) + )); + } +} diff --git a/packages/token-market-contract/src/v1/mod.rs b/packages/token-market-contract/src/v1/mod.rs new file mode 100644 index 00000000000..d87e0a3f6c2 --- /dev/null +++ b/packages/token-market-contract/src/v1/mod.rs @@ -0,0 +1,53 @@ +use crate::error::Error; +use serde_json::Value; + +pub mod document_types { + /// Tokens a holder offers for sale at a price, held in escrow until filled or cancelled. + pub mod ask { + pub const NAME: &str = "ask"; + + pub mod properties { + pub const TOKEN_ID: &str = "tokenId"; + pub const PRICE: &str = "price"; + pub const AMOUNT: &str = "amount"; + pub const REMAINING: &str = "remaining"; + pub const MIN_FILL: &str = "minFill"; + pub const EXPIRES_AT: &str = "expiresAt"; + } + + pub mod indexes { + pub const BOOK: &str = "book"; + pub const DEPTH: &str = "depth"; + pub const BY_OWNER: &str = "byOwner"; + } + } + + /// An offer to buy a token at a price, its credits held in escrow until filled or + /// cancelled. + pub mod bid { + pub const NAME: &str = "bid"; + + pub mod properties { + pub const TOKEN_ID: &str = "tokenId"; + pub const PRICE: &str = "price"; + pub const AMOUNT: &str = "amount"; + pub const REMAINING: &str = "remaining"; + pub const MIN_FILL: &str = "minFill"; + pub const EXPIRES_AT: &str = "expiresAt"; + pub const ESCROW: &str = "escrow"; + } + + pub mod indexes { + pub const BOOK: &str = "book"; + pub const DEPTH: &str = "depth"; + pub const BY_OWNER: &str = "byOwner"; + } + } +} + +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!( + "../../schema/v1/token-market-contract-documents.json" + )) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/token-market-contract/test/bootstrap.js b/packages/token-market-contract/test/bootstrap.js new file mode 100644 index 00000000000..7af04f464d7 --- /dev/null +++ b/packages/token-market-contract/test/bootstrap.js @@ -0,0 +1,30 @@ +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const { expect, use } = require('chai'); +const dirtyChai = require('dirty-chai'); + +const { + default: loadWasmDpp, +} = require('@dashevo/wasm-dpp'); + +use(dirtyChai); +use(sinonChai); + +exports.mochaHooks = { + beforeAll: loadWasmDpp, + + beforeEach() { + if (!this.sinon) { + this.sinon = sinon.createSandbox(); + } else { + this.sinon.restore(); + } + }, + + afterEach() { + this.sinon.restore(); + }, +}; + +global.expect = expect; diff --git a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js new file mode 100644 index 00000000000..cf4c5a6156e --- /dev/null +++ b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js @@ -0,0 +1,103 @@ +const crypto = require('crypto'); + +const { + DashPlatformProtocol, + JsonSchemaError, +} = require('@dashevo/wasm-dpp'); +const generateRandomIdentifier = require('@dashevo/wasm-dpp/lib/test/utils/generateRandomIdentifierAsync'); + +const { expect } = require('chai'); +const tokenMarketContractDocumentsSchema = require('../../schema/v1/token-market-contract-documents.json'); + +const expectJsonSchemaError = (validationResult, errorCount = 1) => { + const errors = validationResult.getErrors(); + expect(errors) + .to + .have + .length(errorCount); + + const error = validationResult.getErrors()[0]; + expect(error) + .to + .be + .instanceof(JsonSchemaError); + + return error; +}; + +describe('Token Market Contract', () => { + let dpp; + let dataContract; + let identityId; + + beforeEach(async () => { + dpp = new DashPlatformProtocol( + { generate: () => crypto.randomBytes(32) }, + ); + + identityId = await generateRandomIdentifier(); + + dataContract = dpp.dataContract.create( + identityId, + BigInt(1), + tokenMarketContractDocumentsSchema, + ); + }); + + const validate = (type, raw) => { + const document = dpp.document.create(dataContract, identityId, type, raw); + return document.validate(dpp.protocolVersion); + }; + + const order = (extra = {}) => ({ + tokenId: crypto.randomBytes(32), + price: 100, + amount: 1000, + remaining: 1000, + minFill: 1, + ...extra, + }); + + ['ask', 'bid'].forEach((type) => { + const raw = (extra = {}) => (type === 'bid' ? order({ escrow: 1, ...extra }) : order(extra)); + + describe(type, () => { + it('should accept a valid order', () => { + expect(validate(type, raw()).isValid()).to.be.true(); + }); + + ['tokenId', 'price', 'amount', 'remaining', 'minFill'].forEach((property) => { + it(`should require ${property}`, () => { + const document = raw(); + delete document[property]; + + const error = expectJsonSchemaError(validate(type, document)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + + ['price', 'amount', 'remaining', 'minFill'].forEach((property) => { + it(`should refuse a ${property} below 1`, () => { + const error = expectJsonSchemaError(validate(type, raw({ [property]: 0 }))); + + expect(error.keyword).to.equal('minimum'); + }); + }); + + it('should refuse an unknown property', () => { + const error = expectJsonSchemaError(validate(type, { ...raw(), note: 'x' })); + + expect(error.keyword).to.equal('additionalProperties'); + }); + }); + }); + + it('should require the escrow of a bid', () => { + const error = expectJsonSchemaError(validate('bid', order())); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('escrow'); + }); +}); diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index 153fe020896..7c20d397160 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -16,6 +16,7 @@ default = [ "token-history-contract", "keywords-contract", "moderation-charters-contract", + "token-market-contract", "mocks", ] @@ -56,6 +57,10 @@ moderation-charters-contract = [ "dash-sdk/moderation-charters-contract", "rs-sdk-trusted-context-provider/moderation-charters-contract", ] +token-market-contract = [ + "dash-sdk/token-market-contract", + "rs-sdk-trusted-context-provider/token-market-contract", +] token_reward_explanations = ["dash-sdk/token_reward_explanations"] diff --git a/yarn.lock b/yarn.lock index 2dfa97f37fc..8bda2dc3cba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,6 +2012,20 @@ __metadata: languageName: unknown linkType: soft +"@dashevo/token-market-contract@workspace:packages/token-market-contract": + version: 0.0.0-use.local + resolution: "@dashevo/token-market-contract@workspace:packages/token-market-contract" + dependencies: + "@dashevo/wasm-dpp": "workspace:*" + chai: "npm:^4.3.10" + dirty-chai: "npm:^2.0.1" + eslint: "npm:^9.18.0" + mocha: "npm:^11.1.0" + sinon: "npm:^18.0.1" + sinon-chai: "npm:^3.7.0" + languageName: unknown + linkType: soft + "@dashevo/wallet-lib@workspace:*, @dashevo/wallet-lib@workspace:packages/wallet-lib": version: 0.0.0-use.local resolution: "@dashevo/wallet-lib@workspace:packages/wallet-lib" From 10c13978772864bfaa6f27addff2eed099d4cf18 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 22:47:34 +0300 Subject: [PATCH 34/62] fix(drive-abci): refuse a tradeable non-transferable token in check_tx The rule that a TokenConfigUpdate can make a token tradeable only if the token is transferable reads nothing but the contract the action carries, so it belongs with the batch's advanced structure, which check_tx runs, rather than in the state validation check_tx skips. Batch advanced structure 2 (DRIVE_ABCI_VALIDATION_VERSIONS_V11, PV15) runs v1 and then the config update's structure validation 0, which checks the proposed trade mode with TokenConfiguration::validate_trade_mode without building the proposed configuration. Config update state validation 2 is removed and V11 keeps state validation 1. Tests: check_tx refuses the update (and admits a transferable token), and contract create and update with a tradeable format-0 token are refused unpaid at protocol version 13 in check_tx and in a block, nonce and balance unchanged, with not-tradeable controls. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 2 +- .../token_configuration/mod.rs | 74 ++++++++++----- .../mod.rs | 42 ++++++--- .../state_v2/mod.rs | 65 -------------- .../structure_v0/mod.rs | 41 +++++++++ .../batch/advanced_structure/mod.rs | 1 + .../batch/advanced_structure/v2/mod.rs | 89 +++++++++++++++++++ .../state_transitions/batch/mod.rs | 31 +++---- .../batch/tests/token/non_transferable/mod.rs | 40 +++++++-- .../contract_structure_test_harness.rs | 58 ++++++++++++ .../contract_structure_error_tests.rs | 46 +++++++++- .../contract_structure_error_tests.rs | 36 +++++++- .../drive_abci_validation_versions/v11.rs | 8 +- .../rs-platform-version/src/version/v15.rs | 8 +- 14 files changed, 411 insertions(+), 130 deletions(-) delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/structure_v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 7a1c871797f..c1743a09bfb 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -44,7 +44,7 @@ created tradeable becomes tradeable only if its issuer kept the right to change | Rule | Refused with | Where | |---|---|---| | `Tradeable` before protocol version 15 | `StateTransitionNotActiveError` (10603), as `TokenTradeModeTradeable` on a contract and `TokenConfigUpdateMarketplaceTradeModeTradeable` on an update | Contract create and update, and the batch carrying the update, before anything is charged. Software older than protocol version 15 cannot decode the mode, so this is the refusal it gives too | -| `Tradeable` on a [non-transferable token](non-transferable-tokens.md) | `NonTransferableTokenTradeableError` (10281) | Contract create and update before anything is charged, and the state validation of a `TokenConfigUpdate`, which charges the signer | +| `Tradeable` on a [non-transferable token](non-transferable-tokens.md) | `NonTransferableTokenTradeableError` (10281) | Contract create and update before anything is charged, and the advanced structure validation of a `TokenConfigUpdate`, which charges the signer and runs in check_tx too, so the mempool refuses the update | A non-transferable token cannot be tradeable because filling a listing or an offer moves the token from the seller to the buyer, which is the transfer the token refuses. `transferable` is fixed diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs index a2c80951c35..f022ecc2f2c 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs @@ -9,6 +9,7 @@ use crate::data_contract::associated_token::token_configuration::accessors::v1:: use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1; use crate::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Getters; +use crate::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use crate::data_contract::change_control_rules::ChangeControlRules; use crate::data_contract::errors::DataContractError; @@ -69,12 +70,16 @@ impl TokenConfiguration { /// configuration that uses one is checked by `validate_token_configurations` before anything /// else. pub fn needs_activation_check(&self) -> bool { - self.format_version() > 0 - || self - .as_cow_v0() - .marketplace_rules - .trade_mode() - .is_tradeable() + self.format_version() > 0 || self.is_tradeable() + } + + /// Whether holders may list the token for sale and make offers for it + /// (`marketplaceRules.tradeMode` `Tradeable`). + pub fn is_tradeable(&self) -> bool { + self.as_cow_v0() + .marketplace_rules + .trade_mode() + .is_tradeable() } /// Checks the configuration's format version against the bounds the platform version @@ -154,20 +159,30 @@ impl TokenConfiguration { /// version 15 (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`): below it the mode is refused as /// not active, the refusal software that predates the marketplace gives by failing to decode /// it. A tradeable token must also be transferable, since filling a listing or an offer moves - /// the token from one identity to another. Checked on contract create and update and on the - /// configuration a `TokenConfigUpdate` proposes; `transferable` is fixed at creation, so only - /// the trade mode can change. + /// the token from one identity to another. Checked on contract create and update. pub fn validate_marketplace_rules( &self, token_contract_position: TokenContractPosition, platform_version: &PlatformVersion, ) -> SimpleConsensusValidationResult { - if !self - .as_cow_v0() - .marketplace_rules - .trade_mode() - .is_tradeable() - { + self.validate_trade_mode( + self.as_cow_v0().marketplace_rules.trade_mode(), + token_contract_position, + platform_version, + ) + } + + /// The rules of [`Self::validate_marketplace_rules`] for `trade_mode` as this token's trade + /// mode: its own, or the one a `TokenConfigUpdate` proposes (`MarketplaceTradeMode`, the only + /// item that changes it; `transferable` is fixed at creation). Borrowing the proposed mode + /// spares building the proposed configuration. + pub fn validate_trade_mode( + &self, + trade_mode: &TokenTradeMode, + token_contract_position: TokenContractPosition, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + if !trade_mode.is_tradeable() { return SimpleConsensusValidationResult::new(); } if platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { @@ -1267,20 +1282,39 @@ mod marketplace_rules_tests { } /// A configuration update that makes a non-transferable token tradeable proposes the same - /// configuration a contract update would, and is refused by the same check. + /// configuration a contract update would, and is refused by the same check: whether the + /// trade mode is the token's own or only proposed. #[test] fn should_refuse_making_a_non_transferable_token_tradeable_through_its_configuration_item() { let mut configuration = not_tradeable(); configuration.set_transferable(false); - configuration.apply_token_configuration_item( - TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + + let proposed = configuration.validate_trade_mode( + &TokenTradeMode::Tradeable, + 0, + PlatformVersion::latest(), ); - let result = configuration.validate_marketplace_rules(0, PlatformVersion::latest()); assert!(matches!( - result.errors.as_slice(), + proposed.errors.as_slice(), [ConsensusError::BasicError( BasicError::NonTransferableTokenTradeableError(_) )] )); + + configuration.apply_token_configuration_item( + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + ); + let applied = configuration.validate_marketplace_rules(0, PlatformVersion::latest()); + assert_eq!(applied.errors, proposed.errors); + } + + /// Proposing the mode every token may have is never refused. + #[test] + fn should_accept_proposing_not_tradeable_for_a_non_transferable_token() { + let mut configuration = not_tradeable(); + configuration.set_transferable(false); + assert!(configuration + .validate_trade_mode(&TokenTradeMode::NotTradeable, 0, protocol_version_14()) + .is_valid()); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs index 0a06b8065de..e14184acf85 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/mod.rs @@ -9,14 +9,22 @@ use crate::error::execution::ExecutionError; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v0::TokenConfigUpdateTransitionActionStateValidationV0; use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v1::TokenConfigUpdateTransitionActionStateValidationV1; -use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v2::TokenConfigUpdateTransitionActionStateValidationV2; +use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::structure_v0::TokenConfigUpdateTransitionActionStructureValidationV0; use crate::platform_types::platform::PlatformStateRef; mod state_v0; mod state_v1; -mod state_v2; +mod structure_v0; pub trait TokenConfigUpdateTransitionActionValidation { + /// Checks of the update that need only the contract the action carries, run with the batch's + /// advanced structure so that check_tx refuses a failing update too. Only the batch's advanced + /// structure 2 (protocol version 15) calls it. + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; + fn validate_state( &self, platform: &PlatformStateRef, @@ -29,6 +37,26 @@ pub trait TokenConfigUpdateTransitionActionValidation { } impl TokenConfigUpdateTransitionActionValidation for TokenConfigUpdateTransitionAction { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_config_update_transition_structure_validation + { + 0 => self.validate_structure_v0(platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenConfigUpdateTransitionAction::validate_structure".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + fn validate_state( &self, platform: &PlatformStateRef, @@ -61,17 +89,9 @@ impl TokenConfigUpdateTransitionActionValidation for TokenConfigUpdateTransition transaction, platform_version, ), - 2 => self.validate_state_v2( - platform, - owner_id, - block_info, - execution_context, - transaction, - platform_version, - ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "TokenConfigUpdateTransitionAction::validate_state".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs deleted file mode 100644 index 1a99f56b157..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/state_v2/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -use dpp::block::block_info::BlockInfo; -use dpp::data_contract::accessors::v1::DataContractV1Getters; -use dpp::prelude::Identifier; -use dpp::validation::SimpleConsensusValidationResult; -use dpp::version::PlatformVersion; -use drive::query::TransactionArg; -use drive::state_transition_action::batch::batched_transition::token_transition::token_config_update_transition_action::{ - TokenConfigUpdateTransitionAction, TokenConfigUpdateTransitionActionAccessorsV0, -}; - -use crate::error::Error; -use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; -use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::state_v1::TokenConfigUpdateTransitionActionStateValidationV1; -use crate::platform_types::platform::PlatformStateRef; - -pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenConfigUpdateTransitionActionStateValidationV2 { - fn validate_state_v2( - &self, - platform: &PlatformStateRef, - owner_id: Identifier, - block_info: &BlockInfo, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result; -} - -impl TokenConfigUpdateTransitionActionStateValidationV2 for TokenConfigUpdateTransitionAction { - /// v1, then the marketplace rules of the configuration the update proposes (protocol version - /// 15): a `MarketplaceTradeMode` item can make a token tradeable only if the token is - /// transferable (`NonTransferableTokenTradeableError`, 10281), the rule contract create and - /// update apply to the configuration they carry. `transferable` is fixed at creation, so the - /// trade mode is the only half of the rule an update can change. - fn validate_state_v2( - &self, - platform: &PlatformStateRef, - owner_id: Identifier, - block_info: &BlockInfo, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result { - let validation_result = self.validate_state_v1( - platform, - owner_id, - block_info, - execution_context, - transaction, - platform_version, - )?; - if !validation_result.is_valid() { - return Ok(validation_result); - } - - let contract = &self.data_contract_fetch_info_ref().contract; - let mut proposed_token_configuration = contract - .expected_token_configuration(self.token_position())? - .clone(); - proposed_token_configuration - .apply_token_configuration_item(self.update_token_configuration_item().clone()); - - Ok(proposed_token_configuration - .validate_marketplace_rules(self.token_position(), platform_version)) - } -} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/structure_v0/mod.rs new file mode 100644 index 00000000000..5a4c2ad9a7b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_config_update_transition_action/structure_v0/mod.rs @@ -0,0 +1,41 @@ +use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::token_transition::token_config_update_transition_action::{ + TokenConfigUpdateTransitionAction, TokenConfigUpdateTransitionActionAccessorsV0, +}; + +use crate::error::Error; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait TokenConfigUpdateTransitionActionStructureValidationV0 +{ + fn validate_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenConfigUpdateTransitionActionStructureValidationV0 for TokenConfigUpdateTransitionAction { + /// The marketplace rules of the trade mode the update proposes (protocol version 15): a + /// `MarketplaceTradeMode` item can make a token tradeable only if the token is transferable + /// (`NonTransferableTokenTradeableError`, 10281), the rule contract create and update apply to + /// the configuration they carry. `transferable` is fixed at creation and no other item changes + /// the trade mode, so the proposed mode is checked against the configuration the action + /// carries, without building the proposed configuration and without a read. + fn validate_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let TokenConfigurationChangeItem::MarketplaceTradeMode(trade_mode) = + self.update_token_configuration_item() + else { + return Ok(SimpleConsensusValidationResult::new()); + }; + Ok(self.base().token_configuration()?.validate_trade_mode( + trade_mode, + self.token_position(), + platform_version, + )) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs index 008be12cc67..4598692d1d6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod v0; pub(crate) mod v1; +pub(crate) mod v2; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs new file mode 100644 index 00000000000..4f762ec3133 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs @@ -0,0 +1,89 @@ +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::Network; +use dpp::identity::PartialIdentity; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionOwned}; +use dpp::validation::ConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::batch::BatchTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::TokenConfigUpdateTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait DocumentsBatchStateTransitionStructureValidationV2 +{ + fn validate_advanced_structure_from_state_v2( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { + /// v1, then the structure checks of token actions that protocol version 15 introduces. They + /// read only the contract each action carries, so they run here, where check_tx runs them + /// too, rather than in the state validation check_tx skips: + /// + /// - a `TokenConfigUpdate` that makes a token tradeable needs a transferable token + /// (`NonTransferableTokenTradeableError`, 10281). + /// + /// A failing action bumps the signer's nonce, as every advanced structure failure does. + fn validate_advanced_structure_from_state_v2( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let result = self.validate_advanced_structure_from_state_v1( + block_info, + network, + action, + identity, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); + } + + for transition in action.transitions() { + let BatchedTransitionAction::TokenAction(token_transition_action) = transition else { + continue; + }; + let structure_result = match token_transition_action { + TokenTransitionAction::ConfigUpdateAction(config_update_action) => { + config_update_action.validate_structure(platform_version)? + } + _ => continue, + }; + if !structure_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition_action( + token_transition_action.base(), + self.owner_id(), + self.user_fee_increase(), + ), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + structure_result.errors, + )); + } + } + + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index 744e94ca6a5..d46e09a61df 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -1,4 +1,5 @@ use advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; +use advanced_structure::v2::DocumentsBatchStateTransitionStructureValidationV2; pub(in crate::execution::validation::state_transition::state_transitions) mod action_validation; mod advanced_structure; mod data_triggers; @@ -290,7 +291,7 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { .batch_state_transition .advanced_structure { - 0 | 1 => { + version @ 0..=2 => { let identity = identity.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "The identity must be known on advanced structure validation", @@ -301,36 +302,36 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { "action must be a documents batch transition action", ))); }; - if platform_version - .drive_abci - .validation_and_processing - .state_transitions - .batch_state_transition - .advanced_structure - == 1 - { - self.validate_advanced_structure_from_state_v1( + match version { + 2 => self.validate_advanced_structure_from_state_v2( block_info, network, documents_batch_transition_action, identity, execution_context, platform_version, - ) - } else { - self.validate_advanced_structure_from_state_v0( + ), + 1 => self.validate_advanced_structure_from_state_v1( block_info, network, documents_batch_transition_action, identity, execution_context, platform_version, - ) + ), + _ => self.validate_advanced_structure_from_state_v0( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ), } } version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: advanced structure from state".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs index c60af355c30..8e1959a5d08 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs @@ -601,9 +601,12 @@ mod tradeable { } } - /// Sends the contract owner's update of the token's trade mode to `Tradeable` in one block, - /// and returns its result with the trade mode the token has afterwards. - async fn make_tradeable(non_transferable: bool) -> (StateTransitionExecutionResult, bool) { + /// Sends the contract owner's update of the token's trade mode to `Tradeable` to the mempool + /// and then in one block, and returns what check_tx refused it with, the block's result and + /// the trade mode the token has afterwards. + async fn make_tradeable( + non_transferable: bool, + ) -> (Vec, StateTransitionExecutionResult, bool) { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() @@ -649,6 +652,22 @@ mod tradeable { .serialize_to_bytes() .expect("expected documents batch serialized state transition"); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let check_tx_errors = platform + .check_tx( + &config_update_serialized_transition, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("expected to be able to check tx") + .errors; + let transaction = platform.drive.grove.start_transaction(); let processing_result = platform @@ -696,12 +715,20 @@ mod tradeable { .into_execution_results() .pop() .expect("expected one execution result"); - (result, is_tradeable) + (check_tx_errors, result, is_tradeable) } + /// The rule reads only the contract the action carries, so it runs with the batch's advanced + /// structure and the mempool refuses the update too. #[tokio::test] async fn should_refuse_making_a_non_transferable_token_tradeable_with_a_paid_error_10281() { - let (result, is_tradeable) = make_tradeable(true).await; + let (check_tx_errors, result, is_tradeable) = make_tradeable(true).await; + assert_matches!( + check_tx_errors.as_slice(), + [ConsensusError::BasicError( + BasicError::NonTransferableTokenTradeableError(_) + )] + ); assert_matches!( result, PaidConsensusError { @@ -716,7 +743,8 @@ mod tradeable { #[tokio::test] async fn should_make_a_transferable_token_tradeable_from_protocol_version_15() { - let (result, is_tradeable) = make_tradeable(false).await; + let (check_tx_errors, result, is_tradeable) = make_tradeable(false).await; + assert!(check_tx_errors.is_empty(), "{check_tx_errors:?}"); assert_matches!( result, StateTransitionExecutionResult::SuccessfulExecution { .. } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs index 728792ba889..f3e21f7078b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs @@ -15,6 +15,14 @@ use assert_matches::assert_matches; use dpp::block::block_info::BlockInfo; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use dpp::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; +use dpp::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization; +use dpp::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Setters; +use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use dpp::data_contract::errors::DataContractError; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; @@ -435,3 +443,53 @@ pub(in crate::execution) fn assert_unpaid_internal_error(outcome: &Outcome, need assert_eq!(outcome.nonce_after, outcome.nonce_before); assert_eq!(outcome.balance_after, outcome.balance_before); } + +/// Gives the contract a token at position 0, in configuration format version 0: the most +/// restrictive configuration with an English name and `trade_mode`, the one part of it the frozen +/// contract basic structure generations of protocol versions 9 to 13 never read. +pub(in crate::execution) fn add_token_with_trade_mode( + contract: &mut DataContractInSerializationFormat, + trade_mode: TokenTradeMode, +) { + let DataContractInSerializationFormat::V1(contract) = contract else { + panic!("expected a contract serialization format that carries tokens"); + }; + let mut configuration = TokenConfigurationV0::default_most_restrictive(); + configuration.marketplace_rules.set_trade_mode(trade_mode); + let mut configuration = TokenConfiguration::V0(configuration); + configuration.conventions_mut().localizations_mut().insert( + "en".to_string(), + TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 { + should_capitalize: true, + singular_form: "test".to_string(), + plural_form: "tests".to_string(), + }), + ); + contract.tokens.insert(0, configuration); +} + +/// An unpaid refusal as not active: `check_tx` and the block refuse the transition with +/// `StateTransitionNotActiveError` under `state_transition_type`, and the owner's nonce and +/// balance stay as they were. +pub(in crate::execution) fn assert_refused_unpaid_as_not_active( + outcome: &Outcome, + state_transition_type: &str, +) { + assert_matches!( + outcome.check_tx.as_deref(), + Ok([ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))]) + if error.state_transition_type() == state_transition_type, + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::BasicError( + BasicError::StateTransitionNotActiveError(error) + )) if error.state_transition_type() == state_transition_type, + "block: {:?}", + outcome.block + ); + assert_eq!(outcome.nonce_after, outcome.nonce_before); + assert_eq!(outcome.balance_after, outcome.balance_before); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs index 469c4316c97..894aeeaa632 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs @@ -2,9 +2,9 @@ //! document meta-schema, through `check_tx` and block processing. use crate::execution::validation::state_transition::state_transitions::data_contract_common::contract_structure_test_harness::{ - add_ref_dag_document_type, assert_paid_contract_structure_error, + add_ref_dag_document_type, add_token_with_trade_mode, assert_paid_contract_structure_error, assert_paid_contract_structure_error_in_block, assert_paid_ref_cycle_error, - assert_unpaid_internal_error, check_and_process, + assert_refused_unpaid_as_not_active, assert_unpaid_internal_error, check_and_process, contested_unbounded_sum_schema, expiring_unbounded_sum_schema, resign_with_contract, summed_u64_schema, terminal_without_index_only_schema, Outcome, CONTESTED_UNBOUNDED_SUM_MESSAGE, EXPIRING_UNBOUNDED_SUM_MESSAGE, SUMMED_U64_MESSAGE, @@ -18,6 +18,7 @@ use assert_matches::assert_matches; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; use dpp::dash_to_credits; +use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use dpp::data_contract::errors::DataContractError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; @@ -520,3 +521,44 @@ async fn should_keep_registering_a_dotted_summable_name_at_protocol_version_13() ); assert_unpaid_internal_error(&create_outcome, "summable property absent"); } + +/// The frozen contract basic structure generations of protocol versions 9 to 13 never read a +/// token's trade mode, so a tradeable token reaches them in a configuration format they accept: +/// the activation gate refuses the contract unpaid before them, in check_tx and in a block. +#[tokio::test] +async fn should_refuse_a_contract_with_a_tradeable_token_unpaid_at_protocol_version_13() { + let outcome = register_contract_with( + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable), + 13, + dash_to_credits!(1.0), + ) + .await + .outcome; + + assert_refused_unpaid_as_not_active(&outcome, "TokenTradeModeTradeable"); +} + +/// The same contract with a token that is not tradeable registers. +#[tokio::test] +async fn should_register_a_contract_with_a_token_that_is_not_tradeable_at_protocol_version_13() { + let outcome = register_contract_with( + |contract| add_token_with_trade_mode(contract, TokenTradeMode::NotTradeable), + 13, + dash_to_credits!(1.0), + ) + .await + .outcome; + + assert_matches!( + outcome.check_tx.as_deref(), + Ok([]), + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "block: {:?}", + outcome.block + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs index f18750b4d22..f0b6efd634a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs @@ -2,9 +2,9 @@ //! through `check_tx` and block processing. use crate::execution::validation::state_transition::state_transitions::data_contract_common::contract_structure_test_harness::{ - add_ref_dag_document_type, assert_paid_contract_structure_error, + add_ref_dag_document_type, add_token_with_trade_mode, assert_paid_contract_structure_error, assert_paid_contract_structure_error_in_block, assert_paid_ref_cycle_error, - assert_unpaid_internal_error, check_and_process, + assert_refused_unpaid_as_not_active, assert_unpaid_internal_error, check_and_process, expiring_summed_schema, resign_with_contract, summed_u64_schema, terminal_without_index_only_schema, Outcome, EXPIRING_UNBOUNDED_SUM_MESSAGE, SUMMED_U64_MESSAGE, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, @@ -17,6 +17,7 @@ use assert_matches::assert_matches; use dpp::block::block_info::BlockInfo; use dpp::dash_to_credits; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; +use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use dpp::data_contract::config::v1::DataContractConfigSettersV1; use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; @@ -350,3 +351,34 @@ async fn should_refuse_an_update_dropping_unique_items_from_an_index_only_indexe UPDATE_NONCE, ); } + +/// An update adding a tradeable token is refused unpaid at protocol version 13 by the same gate +/// as a create, in check_tx and in a block: the update arm reads the trade mode too, not only the +/// configuration's format version. +#[tokio::test] +async fn should_refuse_an_update_adding_a_tradeable_token_unpaid_at_protocol_version_13() { + let outcome = update_fixture_contract( + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable), + 13, + ) + .await; + + assert_refused_unpaid_as_not_active(&outcome, "TokenTradeModeTradeable"); +} + +/// The same update with a token that is not tradeable passes the gate: check_tx admits it. +#[tokio::test] +async fn should_admit_an_update_adding_a_token_that_is_not_tradeable_at_protocol_version_13() { + let outcome = update_fixture_contract( + |contract| add_token_with_trade_mode(contract, TokenTradeMode::NotTradeable), + 13, + ) + .await; + + assert_matches!( + outcome.check_tx.as_deref(), + Ok([]), + "check_tx: {:?}", + outcome.check_tx + ); +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 7d0733c37e3..c6abd1c504d 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -15,9 +15,9 @@ use super::{ /// - `create_domain_data_trigger` 2, which keeps only the parent domain checks of a DPNS domain /// create: the DPNS contract v3 (`SYSTEM_DATA_CONTRACT_VERSIONS_V4`) checks the normalized /// names, the preorder and the identity record with schema keywords. -/// - `token_config_update_transition_state_validation` 2, which also checks the marketplace rules -/// of the configuration an update proposes: a token can be made tradeable only if it is -/// transferable. +/// - the batch `advanced_structure` 2: v1, then the structure checks of token actions protocol +/// v15 introduces, which read only the contract the action carries and so run in check_tx too. +/// A `TokenConfigUpdate` can make a token tradeable only if it is transferable. /// /// v10 remains unchanged for `PROTOCOL_VERSION_14` chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = @@ -30,7 +30,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = .identity_update_state_transition }, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { - token_config_update_transition_state_validation: 2, // a tradeable token must be transferable + advanced_structure: 2, // a tradeable token must be transferable, checked in check_tx too data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { triggers: DriveAbciValidationDataTriggerVersions { create_domain_data_trigger: 2, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index c71af01508c..5182c6f91a3 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -132,10 +132,10 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `TokenConfigUpdate` that makes a token tradeable, are refused unpaid as not active /// (`StateTransitionNotActiveError`, 10603), the refusal older software gives by failing to /// decode the mode. A tradeable token must be transferable (`NonTransferableTokenTradeableError`, -/// 10281): contract create and update refuse it unpaid, and -/// `token_config_update_transition_state_validation` 2 -/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the configuration an update proposes, charged. -/// Protocol v14 and earlier keep `token_config_update_transition_state_validation` 1. +/// 10281): contract create and update refuse it unpaid, and the batch `advanced_structure` 2 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the trade mode an update proposes against the +/// contract the action carries, charged, so check_tx refuses the update too. Protocol v14 and +/// earlier keep the batch `advanced_structure` 1. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, From 9c128e6b589c56b9eb5075088879ffd8847344e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 00:04:29 +0300 Subject: [PATCH 35/62] fix(swift-sdk): keep a Tradeable token tradeable when a contract is parsed The live TokenTradeMode knew only "NotTradeable", so DataContractParser's TokenTradeMode(rawValue:) returned nil for "Tradeable" and the token kept the notTradeable default, making PersistentToken.isTradeable false for a tradeable token. Add case tradeable = "Tradeable". The value is stored as its raw string, so the V4 entity hash and checksum do not move (checked against a live store); the frozen V1 to V3 schema copies stay as they are. Also point the non-transferable tokens table at the batch advanced structure, where a TokenConfigUpdate making such a token tradeable is now refused. Co-Authored-By: Claude Opus 5.5 --- .../src/data-model/non-transferable-tokens.md | 2 +- .../Persistence/Types/TokenTypes.swift | 9 +- .../DataContractParserTradeModeTests.swift | 125 ++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift diff --git a/book/src/data-model/non-transferable-tokens.md b/book/src/data-model/non-transferable-tokens.md index b902320dfde..800043a1783 100644 --- a/book/src/data-model/non-transferable-tokens.md +++ b/book/src/data-model/non-transferable-tokens.md @@ -46,7 +46,7 @@ contract update cannot change an existing token's configuration. | A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed. A document payment refuses it again (`TokenNotTransferableError`, 40726) from the configuration the contract already carries, so a contract that bypassed registration cannot pay with it either | | A document type of another contract charging it | `TokenNotTransferableError` (40726) | Contract create and update state validation, which already reads the token's contract to check the token exists. Such a cost always pays the contract owner, since another contract's token cannot be burned (10261) | | `hasShieldedPool: true` | `NonTransferableTokenShieldedPoolError` (10279) | Contract create and update, before anything is charged | -| `marketplaceRules.tradeMode: Tradeable` (protocol version 15) | `NonTransferableTokenTradeableError` (10281) | Contract create and update, before anything is charged, and the state validation of a `TokenConfigUpdate` that makes the token tradeable | +| `marketplaceRules.tradeMode: Tradeable` (protocol version 15) | `NonTransferableTokenTradeableError` (10281) | Contract create and update, before anything is charged, and the batch advanced structure validation of a `TokenConfigUpdate` that makes the token tradeable: the mempool refuses it, and a block charges the signer and bumps the nonce | A shielded pool is refused because a note spent inside the pool can be unshielded to any identity, so shielding and unshielding would move the token between holders. The diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index eeb4fb24371..cd22aefcdfe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -241,14 +241,21 @@ public enum TokenDistributionRecipient: String, CaseIterable, Codable, Sendable // MARK: - Token Trade Mode -/// Trading modes for tokens +/// Trading modes for tokens. Raw values match rs-dpp's `TokenTradeMode` in +/// contract JSON. `tradeable` is valid from protocol version 15. +/// +/// Stored on `PersistentToken` as its raw string, so a new case leaves the +/// entity hash unchanged. The frozen schema copies keep only `notTradeable`. public enum TokenTradeMode: String, CaseIterable, Codable, Sendable { case notTradeable = "NotTradeable" + case tradeable = "Tradeable" public var displayName: String { switch self { case .notTradeable: return "Not Tradeable" + case .tradeable: + return "Tradeable" } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift new file mode 100644 index 00000000000..cfa9bc730e8 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift @@ -0,0 +1,125 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Coverage for `DataContractParser`'s token trade mode parsing. +/// +/// rs-dpp serialises `TokenMarketplaceRules` flat, tagged with its format +/// version, and `TokenTradeMode` as a bare string: +/// +/// ```json +/// "marketplaceRules": { +/// "$formatVersion": "0", +/// "tradeMode": "Tradeable", +/// "tradeModeChangeRules": { "$formatVersion": "0", ... } +/// } +/// ``` +/// +/// "Tradeable" is valid from protocol version 15. The Swift enum used to +/// have only `notTradeable`, so the parser's `TokenTradeMode(rawValue:)` +/// returned nil for it and the token kept the `notTradeable` default, which +/// made `PersistentToken.isTradeable` report false for a tradeable token. +/// +/// These tests drive the public `parseDataContract` entry point against an +/// in-memory `ModelContainer` (no network, no FFI handle), save, and read the +/// token back through a fresh context so the stored value is what is checked. +@MainActor +final class DataContractParserTradeModeTests: XCTestCase { + + private let contractId = Data(repeating: 0xAB, count: 32) + + /// The parser's `parseTokens` bails out early unless a + /// `PersistentDataContract` row already exists for `contractId` + /// (tokens hang off the contract relationship). Seed that row, run the + /// parser, save, and pass `body` the single token read through a new + /// context on the same container. The context lives until `body` returns. + private func withParsedToken( + _ tokenDict: [String: Any], + _ body: (PersistentToken) throws -> Void + ) throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + let contract = PersistentDataContract( + id: contractId, + name: "Fixture", + serializedContract: Data(), + network: .testnet + ) + context.insert(contract) + try context.save() + + let contractData: [String: Any] = [ + "tokens": ["0": tokenDict] + ] + + try DataContractParser.parseDataContract( + contractData: contractData, + contractId: contractId, + modelContext: context + ) + try context.save() + + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + let readContext = ModelContext(container) + let tokens = try readContext.fetch(descriptor) + XCTAssertEqual(tokens.count, 1) + let token = try XCTUnwrap(tokens.first, "parser should have persisted one token") + try withExtendedLifetime(readContext) { + try body(token) + } + } + + /// A token configuration whose `marketplaceRules` carries `tradeMode` + /// in the real rs-dpp wire shape. + private func tokenDict(tradeMode: String) -> [String: Any] { + return [ + "$formatVersion": "0", + "baseSupply": 0, + "marketplaceRules": [ + "$formatVersion": "0", + "tradeMode": tradeMode, + "tradeModeChangeRules": [ + "$formatVersion": "0", + "authorizedToMakeChange": [ + "$type": AuthorizedActionTakers.WireType.contractOwner + ], + "adminActionTakers": [ + "$type": AuthorizedActionTakers.WireType.noOne + ] + ] + ] + ] + } + + func testParsesTradeableTradeModeFromContractJSON() throws { + try withParsedToken(tokenDict(tradeMode: "Tradeable")) { token in + XCTAssertEqual(token.tradeMode, .tradeable) + XCTAssertTrue(token.isTradeable) + XCTAssertEqual(token.tradeMode.displayName, "Tradeable") + // The change rules next to it still parse. + XCTAssertEqual( + token.tradeModeChangeRules?.authorizedToMakeChange, + AuthorizedActionTakers.contractOwner.rawValue + ) + } + } + + func testNotTradeableTradeModeStaysNotTradeable() throws { + try withParsedToken(tokenDict(tradeMode: "NotTradeable")) { token in + XCTAssertEqual(token.tradeMode, .notTradeable) + XCTAssertFalse(token.isTradeable) + XCTAssertEqual(token.tradeMode.displayName, "Not Tradeable") + } + } + + /// The raw values are the strings rs-dpp writes; a rename on either side + /// would silently fall back to the default again. + func testTradeModeRawValuesMatchTheRustSerdeNames() { + XCTAssertEqual(TokenTradeMode.notTradeable.rawValue, "NotTradeable") + XCTAssertEqual(TokenTradeMode.tradeable.rawValue, "Tradeable") + } +} From c7f7fb9964bd04846ab7fc1fd546ecdcd9b5f40e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 00:56:32 +0300 Subject: [PATCH 36/62] fix(platform): token market review round 1 - The token market contract's JS spec builds its fixture with sizedIntegerTypes false, as the deployed contract is: the default config infers u64 for the summed remaining and escrow, which a sum cannot hold, so every test failed in beforeEach. - The genesis-vs-upgrade equivalence test compares [Tokens] and [PreFundedSpecializedBalances] each on its own: one call with both read them as one path and compared nothing. - add_to_token_order_escrow_operations takes the batch's earlier operations, so two listings of a new token in one batch insert its escrow tree once. - The escrow cost estimation keeps a deeper Tokens layer estimate rather than overwriting it. - Tests: estimation of every escrow write without writing, the i64 bounds of both escrows, the one-tree batch, and the order types' remaining document-transition defaults (not transferable, no trade mode, not index-only). - v15's table annotation names the token market contract. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/system_data_contracts.rs | 23 +- .../v0/mod.rs | 17 +- .../order_escrow/estimated_costs/v0/mod.rs | 35 +- .../src/drive/tokens/order_escrow/mod.rs | 346 ++++++++++++++++++ .../drive/tokens/order_escrow/tokens/mod.rs | 7 + .../tokens/order_escrow/tokens/v0/mod.rs | 7 +- packages/rs-drive/src/structure/tests.rs | 1 + .../rs-platform-version/src/version/v15.rs | 2 +- .../test/unit/tokenMarketContract.spec.js | 24 +- 9 files changed, 439 insertions(+), 23 deletions(-) diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index c7e1a35c52d..fee5d780303 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -1393,9 +1393,13 @@ mod dpns_v3_tests { mod token_market_tests { use super::*; use crate::data_contract::accessors::v0::DataContractV0Getters; - use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; use crate::data_contract::document_type::restricted_creation::CreationRestrictionMode; use crate::data_contract::document_type::DocumentPropertyType; + use crate::document::transfer::Transferable; + use crate::nft::TradeMode; use crate::system_data_contracts::token_market_contract::v1::document_types::{ask, bid}; /// The order types are what the marketplace transitions rely on: only they create orders, @@ -1428,6 +1432,23 @@ mod token_market_tests { CreationRestrictionMode::NoCreationAllowed, "only the marketplace transitions create {name} orders" ); + // The data trigger bindings refuse a user's replace and delete; these defaults + // refuse the remaining document transitions (transfer, purchase, price update and + // index-only delete), so a schema edit that enables one fails here. + assert_eq!( + document_type.documents_transferable(), + Transferable::Never, + "{name} orders cannot be transferred" + ); + assert_eq!( + document_type.trade_mode(), + TradeMode::None, + "{name} orders cannot be priced or purchased as documents" + ); + assert!( + !document_type.index_only(), + "{name} orders are not index-only" + ); for property in document_type.properties().values() { if matches!( property.property_type, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 29f22cffb7e..f9b5d77a64f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -3985,12 +3985,17 @@ mod tests { RootTree::Tokens as u8, RootTree::PreFundedSpecializedBalances as u8, ]; - let diffs = collect_subtree_diffs( - &platform_a, - &platform_b, - &txn_b, - roots.iter().map(|root| vec![*root]).collect(), - ); + // `collect_subtree_diffs` walks one subtree from its path, so each root is compared on + // its own. + let mut diffs = Vec::new(); + for root in roots { + diffs.extend(collect_subtree_diffs( + &platform_a, + &platform_b, + &txn_b, + vec![vec![root]], + )); + } assert!( diffs.is_empty(), "CONSENSUS FORK: the [Tokens] or [PreFundedSpecializedBalances] subtree differs \ diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs index 1af40a9660b..a4fdfe53f78 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs @@ -14,6 +14,9 @@ use grovedb::EstimatedSumTrees::{AllBigSumTrees, AllSumTrees, NoSumTrees, SomeSu use grovedb::{EstimatedLayerInformation, TreeType}; use std::collections::HashMap; +/// The level of the Tokens Merk the escrow key sits on. +const TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS: u32 = 3; + impl Drive { /// The layers a write to one listing's escrow entry goes through: the root, the Tokens /// tree, where the escrow BigSumTree is a leaf on the third level, the escrow BigSumTree of @@ -31,14 +34,30 @@ impl Drive { }, ); - estimated_costs_only_with_layer_info.insert( - KeyInfoPath::from_known_path(tokens_root_path()), - EstimatedLayerInformation { - tree_type: TreeType::NormalTree, - estimated_layer_count: EstimatedLevel(3, false), - estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), - }, - ); + // The escrow key sits deeper in the Tokens Merk than the balances key the token balance + // estimation describes at level 0, and a batch moving tokens into or out of escrow writes + // both. One description covers the whole layer, so the deeper one is kept whichever is + // registered first: this estimation never lowers what is there. The balance estimation + // overwrites what it finds and belongs to every earlier protocol version's fees, so the + // escrow operations register theirs after it (`TokenPlaceInOrderEscrow`, + // `TokenReleaseFromOrderEscrow`). + let tokens_layer = EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS, false), + estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), + }; + match estimated_costs_only_with_layer_info + .get(&KeyInfoPath::from_known_path(tokens_root_path())) + .map(|existing| &existing.estimated_layer_count) + { + Some(EstimatedLevel(level, _)) if *level >= TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS => {} + _ => { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(tokens_root_path()), + tokens_layer, + ); + } + } estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_path(token_order_escrow_root_path()), diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index a7bebc2b6b7..053a0a063ea 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -12,12 +12,19 @@ mod tokens; #[cfg(test)] mod tests { + use crate::drive::tokens::paths::tokens_root_path; use crate::drive::Drive; use crate::fees::op::LowLevelDriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; + use grovedb::batch::KeyInfoPath; + use grovedb::EstimatedLayerCount::EstimatedLevel; + use grovedb::EstimatedLayerSizes::AllSubtrees; + use grovedb::EstimatedSumTrees::AllBigSumTrees; + use grovedb::{EstimatedLayerInformation, TreeType}; + use std::collections::HashMap; fn apply(drive: &Drive, operations: Vec) { drive @@ -55,6 +62,7 @@ mod tests { 500, &mut None, None, + &mut None, platform_version, ) .expect("expected the escrow operations"), @@ -186,6 +194,7 @@ mod tests { 400, &mut None, None, + &mut None, platform_version, ) .expect("expected the escrow operations"), @@ -208,4 +217,341 @@ mod tests { .expect("expected to calculate the total tokens balance"); assert!(!before_the_marketplace.ok().expect("expected no overflow")); } + + fn fetch_listing(drive: &Drive, token_id: [u8; 32], order_id: [u8; 32]) -> Option { + drive + .fetch_token_order_escrow( + token_id, + order_id, + None, + &mut vec![], + PlatformVersion::latest(), + ) + .expect("expected to fetch the escrow") + } + + fn fetch_offer(drive: &Drive, order_id: [u8; 32]) -> Option { + drive + .fetch_credit_order_escrow(order_id, None, &mut vec![], PlatformVersion::latest()) + .expect("expected to fetch the escrow") + } + + /// Two listings of a token that has none, built for one batch: the second sees the escrow + /// tree the first queues and does not insert it again. + #[test] + fn should_insert_a_tokens_escrow_tree_once_for_two_listings_in_one_batch() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + + let mut batch = drive + .add_to_token_order_escrow_operations( + token_id, + [1; 32], + 10, + &mut None, + None, + &mut None, + platform_version, + ) + .expect("expected the first listing's operations"); + let second = drive + .add_to_token_order_escrow_operations( + token_id, + [2; 32], + 20, + &mut None, + None, + &mut Some(&mut batch), + platform_version, + ) + .expect("expected the second listing's operations"); + batch.extend(second); + apply(&drive, batch); + + assert_eq!(fetch_listing(&drive, token_id, [1; 32]), Some(10)); + assert_eq!(fetch_listing(&drive, token_id, [2; 32]), Some(20)); + } + + /// Without the operations queued before it, the second listing inserts the tree again, and + /// the batch is refused: what the shared operations prevent. + #[test] + fn should_refuse_a_batch_inserting_a_tokens_escrow_tree_twice() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let mut batch = vec![]; + for order_id in [[1; 32], [2; 32]] { + batch.extend( + drive + .add_to_token_order_escrow_operations( + token_id, + order_id, + 10, + &mut None, + None, + &mut None, + platform_version, + ) + .expect("expected the listing's operations"), + ); + } + assert!(drive + .apply_batch_low_level_drive_operations( + None, + None, + batch, + &mut vec![], + &platform_version.drive, + ) + .is_err()); + } + + /// Every escrow write can be priced without state: the operations built for estimation + /// cost something to apply and change nothing. + #[test] + fn should_estimate_every_escrow_write_without_writing_it() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let order_id = [9; 32]; + + type Build<'a> = Box< + dyn Fn( + &mut Option>, + ) -> Vec + + 'a, + >; + let writes: Vec<(&str, Build)> = vec![ + ( + "add a listing (the token's first)", + Box::new(|estimation| { + drive + .add_to_token_order_escrow_operations( + token_id, + order_id, + 500, + estimation, + None, + &mut None, + platform_version, + ) + .expect("expected the operations") + }), + ), + ( + "set a listing", + Box::new(|estimation| { + drive + .set_token_order_escrow_operations( + token_id, + order_id, + 200, + estimation, + platform_version, + ) + .expect("expected the operations") + }), + ), + ( + "remove a listing", + Box::new(|estimation| { + drive + .remove_token_order_escrow_operations( + token_id, + order_id, + estimation, + platform_version, + ) + .expect("expected the operations") + }), + ), + ( + "add an offer", + Box::new(|estimation| { + drive + .add_to_credit_order_escrow_operations( + order_id, + 70_000, + estimation, + platform_version, + ) + .expect("expected the operations") + }), + ), + ( + "set an offer", + Box::new(|estimation| { + drive + .set_credit_order_escrow_operations( + order_id, + 30_000, + estimation, + platform_version, + ) + .expect("expected the operations") + }), + ), + ( + "remove an offer", + Box::new(|estimation| { + drive + .remove_credit_order_escrow_operations( + order_id, + estimation, + platform_version, + ) + .expect("expected the operations") + }), + ), + ]; + + for (name, build) in writes { + let mut estimation = Some(HashMap::new()); + let operations = build(&mut estimation); + let mut costs = vec![]; + drive + .apply_batch_low_level_drive_operations( + estimation, + None, + operations, + &mut costs, + &platform_version.drive, + ) + .unwrap_or_else(|error| panic!("{name}: expected to estimate: {error:?}")); + let fee = Drive::calculate_fee( + None, + Some(costs), + &BlockInfo::default().epoch, + drive.config.epochs_per_era, + platform_version, + None, + ) + .unwrap_or_else(|error| panic!("{name}: expected a fee: {error:?}")); + assert!( + fee.processing_fee > 0, + "{name}: an estimated write costs something" + ); + assert_eq!(fetch_listing(&drive, token_id, order_id), None, "{name}"); + assert_eq!(fetch_offer(&drive, order_id), None, "{name}"); + } + } + + /// The escrow estimation describes the Tokens layer at the escrow key's depth, and keeps a + /// deeper description already registered for the same batch. + #[test] + fn should_keep_the_deeper_tokens_layer_estimate() { + let platform_version = PlatformVersion::latest(); + let tokens_layer_level = + |estimation: &HashMap| match estimation + .get(&KeyInfoPath::from_known_path(tokens_root_path())) + .map(|layer| &layer.estimated_layer_count) + { + Some(EstimatedLevel(level, _)) => *level, + other => panic!("expected an estimated level, got {other:?}"), + }; + + // After the token balance estimation (level 0, the balances key), the escrow's is kept. + let mut estimation = HashMap::new(); + Drive::add_estimation_costs_for_token_balances( + [5; 32], + &mut estimation, + &platform_version.drive, + ) + .expect("expected the balance estimation"); + assert_eq!(tokens_layer_level(&estimation), 0); + Drive::add_estimation_costs_for_token_order_escrow( + [5; 32], + &mut estimation, + platform_version, + ) + .expect("expected the escrow estimation"); + assert_eq!(tokens_layer_level(&estimation), 3); + + // A deeper description stays. + let mut estimation = HashMap::from([( + KeyInfoPath::from_known_path(tokens_root_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(5, false), + estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), + }, + )]); + Drive::add_estimation_costs_for_token_order_escrow( + [5; 32], + &mut estimation, + platform_version, + ) + .expect("expected the escrow estimation"); + assert_eq!(tokens_layer_level(&estimation), 5); + } + + /// Both escrows hold signed 8-byte values: `i64::MAX` is held, one more is refused. + #[test] + fn should_hold_up_to_i64_max_in_either_escrow() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let largest = i64::MAX as u64; + + apply( + &drive, + drive + .add_to_token_order_escrow_operations( + token_id, + [1; 32], + largest, + &mut None, + None, + &mut None, + platform_version, + ) + .expect("expected i64::MAX to be held"), + ); + assert_eq!(fetch_listing(&drive, token_id, [1; 32]), Some(largest)); + assert!(drive + .add_to_token_order_escrow_operations( + token_id, + [2; 32], + largest + 1, + &mut None, + None, + &mut None, + platform_version, + ) + .is_err()); + assert!(drive + .set_token_order_escrow_operations( + token_id, + [1; 32], + largest + 1, + &mut None, + platform_version, + ) + .is_err()); + + apply( + &drive, + drive + .add_to_credit_order_escrow_operations( + [3; 32], + largest, + &mut None, + platform_version, + ) + .expect("expected i64::MAX to be held"), + ); + assert_eq!(fetch_offer(&drive, [3; 32]), Some(largest)); + assert!(drive + .add_to_credit_order_escrow_operations( + [4; 32], + largest + 1, + &mut None, + platform_version + ) + .is_err()); + assert!(drive + .set_credit_order_escrow_operations([3; 32], largest + 1, &mut None, platform_version) + .is_err()); + } } diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs index 520d369a06d..851858ccc3c 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs @@ -15,6 +15,11 @@ impl Drive { /// `order_id`, creating the token's escrow tree with its first listing. The caller removes /// the amount from the seller's balance in the same batch: escrow moves tokens, it does not /// create them, and the token conservation check counts the escrow. + /// + /// `previous_batch_operations` are the operations already queued for the same batch: a + /// caller placing several listings of a token in one batch passes them, so the token's escrow + /// tree is inserted once, by the first. + #[allow(clippy::too_many_arguments)] pub fn add_to_token_order_escrow_operations( &self, token_id: [u8; 32], @@ -24,6 +29,7 @@ impl Drive { HashMap, >, transaction: TransactionArg, + previous_batch_operations: &mut Option<&mut Vec>, platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version.drive.methods.token.order_escrow.add_tokens { @@ -33,6 +39,7 @@ impl Drive { amount, estimated_costs_only_with_layer_info, transaction, + previous_batch_operations, platform_version, ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs index 564abab13ec..fe6dc2a791d 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs @@ -26,6 +26,7 @@ fn escrow_sum_value(amount: TokenAmount) -> Result { } impl Drive { + #[allow(clippy::too_many_arguments)] pub(super) fn add_to_token_order_escrow_operations_v0( &self, token_id: [u8; 32], @@ -35,6 +36,7 @@ impl Drive { HashMap, >, transaction: TransactionArg, + previous_batch_operations: &mut Option<&mut Vec>, platform_version: &PlatformVersion, ) -> Result, Error> { let mut drive_operations = vec![]; @@ -56,14 +58,15 @@ impl Drive { } }; - // The token's escrow tree is created with its first listing. + // The token's escrow tree is created with its first listing, whether that is in state or + // queued earlier in the same batch. self.batch_insert_empty_tree_if_not_exists( PathFixedSizeKeyRef::<2>((token_order_escrow_root_path(), token_id.as_slice())), TreeType::SumTree, None, tree_apply_type, transaction, - &mut None, + previous_batch_operations, &mut drive_operations, &platform_version.drive, )?; diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 6791241903e..06844a5561c 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1766,6 +1766,7 @@ mod fixtures { amount, &mut None, None, + &mut None, platform_version, ) .expect("expected the listing escrow operations"), diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 84efcda97e6..5c2a00ef53d 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -176,7 +176,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3) + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3); the token market contract is selected (note 6) fee_version: FEE_VERSION3, system_limits: SYSTEM_LIMITS_V4, consensus: ConsensusVersions { diff --git a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js index cf4c5a6156e..91c085d1221 100644 --- a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js +++ b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js @@ -37,11 +37,25 @@ describe('Token Market Contract', () => { identityId = await generateRandomIdentifier(); - dataContract = dpp.dataContract.create( - identityId, - BigInt(1), - tokenMarketContractDocumentsSchema, - ); + // The deployed contract stores every integer as a signed 8-byte value + // (`sizedIntegerTypes: false`), as a summed property must be: the default + // config would infer u64 for `remaining` and `escrow`, which a sum cannot + // hold. `create` takes no config, so the schemas go into the raw form of a + // placeholder contract whose config is set to match, validated on load. + const placeholder = dpp.dataContract.create(identityId, BigInt(1), { + placeholder: { + type: 'object', + properties: { + name: { type: 'string', maxLength: 1, position: 0 }, + }, + additionalProperties: false, + }, + }); + const rawDataContract = placeholder.toObject(); + rawDataContract.config.sizedIntegerTypes = false; + rawDataContract.documentSchemas = tokenMarketContractDocumentsSchema; + + dataContract = await dpp.dataContract.createFromObject(rawDataContract); }); const validate = (type, raw) => { From d85d2c2721fa6bc60df71707951c887454f1d692 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 08:45:59 +0300 Subject: [PATCH 37/62] fix(platform): token market review round 2 - The ten escrow methods are OptionalFeatureVersion: None in every table before protocol version 15, Some(0) in DRIVE_TOKEN_METHOD_VERSIONS_V3; an earlier protocol version gets VersionNotActive instead of operations for trees that do not exist there. - The credit escrow describes the prefunded specialized balances layer at level 2, its key's depth (32 under 64 under 128). Every escrow description keeps a deeper one the batch already registered (util::estimated_layers), and from 15 the voting balance estimation (2) and the contract fee pot estimation (1) put back a deeper description they replace. - The ask and bid documents carry decimals, the token's decimals when the order was placed, which every fill prices the order at. Tests: every escrow method refuses at 14 and works at the latest; the estimate of each escrow write covers its applied cost; the prefunded layer stays at level 2 whichever estimation registers first, and at 14 the siblings behave as before. Genesis at 15 stores the larger token market contract, so the ten nullifier_batch_guard digests that cover the state root are re-pinned. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 7 + .../token/shielded/nullifier_batch_guard.rs | 20 +- .../contract/fee_pots/estimated_costs/mod.rs | 10 +- .../fee_pots/estimated_costs/v1/mod.rs | 31 ++ .../mod.rs | 9 +- .../v2/mod.rs | 25 ++ .../drive/tokens/order_escrow/credits/mod.rs | 32 +- .../order_escrow/estimated_costs/mod.rs | 16 +- .../order_escrow/estimated_costs/v0/mod.rs | 63 ++-- .../src/drive/tokens/order_escrow/mod.rs | 326 ++++++++++++++++++ .../drive/tokens/order_escrow/tokens/mod.rs | 32 +- .../rs-drive/src/util/estimated_layers.rs | 122 +++++++ packages/rs-drive/src/util/mod.rs | 3 + .../drive_token_method_versions/mod.rs | 25 +- .../drive_token_method_versions/v1.rs | 24 +- .../drive_token_method_versions/v3.rs | 19 +- .../src/version/drive_versions/v10.rs | 26 +- .../rs-platform-version/src/version/v15.rs | 9 +- packages/token-market-contract/README.md | 8 +- .../v1/token-market-contract-documents.json | 20 +- packages/token-market-contract/src/v1/mod.rs | 2 + .../test/unit/tokenMarketContract.spec.js | 12 +- 22 files changed, 745 insertions(+), 96 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs create mode 100644 packages/rs-drive/src/util/estimated_layers.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 904b06bc3a4..693fa3d2834 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -68,6 +68,13 @@ the token's supply, and the credits offered never exceed the credits on Platform an order is a signed 8-byte value, so a partial fill rewrites an order without changing its stored size. +An order's price is in credits per whole token, and each order records the token's decimals when +it was placed (`decimals`). Every fill prices the order at that scale, so a later change to the +token's decimals (a `Conventions` update) changes neither what an open listing costs nor what an +open offer pays. The `book` and `depth` indexes order by price alone: after a token changes its +decimals, orders placed before and after the change are priced at different scales until the older +ones close, and a client comparing them scales each by its own `decimals`. + Only the marketplace transitions write orders. A document transition cannot create one (`creationRestrictionMode` 2), and a replace or a delete of one is refused by a data trigger. The types stay mutable and deletable so the documents carry a revision and storage flags: the order's diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index da346f5d286..2a46c60e4f3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "d4d3e450ba03232065a1444d56c3bbad58948715d8226d7ac37ae2596544b7b0" + "3cd7088231bf026b2002ee5fa34eddea03dd5f252a40c318c902b0b83dae8e15" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "10584c4856a0a7897c2258c2c51d6f4141b5eeb38ebaccf90b5a14ef493cd154", + "a58cd30f45254b0672572ab20dd7ad0f2c6e8d029b7ceea6da1bc0fbd4e71171", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "dab93d242f9e364877a51944dafddde83a7584cf2e99eb4076994d21c506d9dc", + "93e04980a29c1fc7bd691268b987ae17a871e6377f2f8e1e1dbbc959f1180df8", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "148b96fd9a5a577c6b1a1e59e8bf629db3379b14c5c8ab6c92268ece85f1f06c", + "8391d9d18bf6bd05b08c31c850c569e5ae6949afe50f0a7ec7c80adb76f4c7bd", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "442cdee8f9c256c642306cab8abfd53362e3c1f38c5529587999ba0bf318764e", + "03ad65af7edfd7e963aa72868147f05302790c2a1b76647a9a67bbfd246ebea7", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "ddcd591c01930437263911b1282a83011e1327873c8bf8486df75db141c50f72", + "3d47f7e8ae92b08b2ac19776f3d1549eaf49165cf2d512c02b2c997136cff866", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "8faa85cda86ea6ec3cd189d629cc218e5b73976114cbbcd092f16fa4f578879b", + "c80b00db4cec75f000fd5052e85bff5dce01f450249aa3640007b2f5c8c4f041", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "e5531fb8b5b7f937aa33f0eb499db2516cdcbd00c59f898edf9d36b272287df7", + "810a4a2a5ffa91ea87d68a901baa97c325131eaacc120c9efffba2e1c1bfe7dc", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "136802e28210b47624836d21ed7fcfd3cea8d7dd37400977081363a3b1429776", + "3101ec83ef0116744dfa36d97f22c6a32b9beca6665f3694b61794cf18532143", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "16ee120ee53d4b8eccb11419bfd128550d01a471803b508a809e0da6f5f81a44", + "1ec71b89fba79084473b286dd1064dc6e456d1b449cdf85fde38073b75e16ee6", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs index 8fdfb894853..ad25febcde2 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -40,9 +41,16 @@ impl Drive { ); Ok(()) } + 1 => { + Self::add_estimation_costs_for_contract_fee_pot_update_v1( + pot, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_contract_fee_pot_update".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v1/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v1/mod.rs new file mode 100644 index 00000000000..6f2f4afda69 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v1/mod.rs @@ -0,0 +1,31 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; +use crate::drive::Drive; +use crate::util::estimated_layers::register_keeping_deeper_estimated_layers; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// v0 (protocol version 15), keeping a deeper description of the prefunded balances layer + /// that another write of the batch registered: the escrow of the token marketplace's offers + /// sits a level below the fee pots' keys. + #[inline(always)] + pub(super) fn add_estimation_costs_for_contract_fee_pot_update_v1( + pot: ContractFeePot, + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + register_keeping_deeper_estimated_layers( + estimated_costs_only_with_layer_info, + &[KeyInfoPath::from_known_path( + prefunded_specialized_balances_path(), + )], + |estimated_costs_only_with_layer_info| { + Self::add_estimation_costs_for_contract_fee_pot_update_v0( + pot, + estimated_costs_only_with_layer_info, + ) + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs index 99404d3360f..8e9c11b2057 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -45,9 +46,15 @@ impl Drive { ); Ok(()) } + 2 => { + Self::add_estimation_costs_for_prefunded_specialized_balance_update_v2( + estimated_costs_only_with_layer_info, + ); + Ok(()) + } version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_prefunded_specialized_balance_update".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs new file mode 100644 index 00000000000..8c3ec55c6bb --- /dev/null +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs @@ -0,0 +1,25 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; +use crate::drive::Drive; +use crate::util::estimated_layers::register_keeping_deeper_estimated_layers; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds estimation costs for a prefunded specialized balance update (protocol version 15): + /// v1, keeping a deeper description of the prefunded balances layer that another write of + /// the batch registered: the escrow of the token marketplace's offers sits two levels below + /// the voting balances' key in that layer. + #[inline(always)] + pub(super) fn add_estimation_costs_for_prefunded_specialized_balance_update_v2( + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + register_keeping_deeper_estimated_layers( + estimated_costs_only_with_layer_info, + &[KeyInfoPath::from_known_path( + prefunded_specialized_balances_path(), + )], + Self::add_estimation_costs_for_prefunded_specialized_balance_update_v1, + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs index a189f5ebcb4..362f195619f 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs @@ -30,17 +30,21 @@ impl Drive { .order_escrow .add_credits { - 0 => self.add_to_credit_order_escrow_operations_v0( + Some(0) => self.add_to_credit_order_escrow_operations_v0( order_id, credits, estimated_costs_only_with_layer_info, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_to_credit_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_to_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -62,17 +66,21 @@ impl Drive { .order_escrow .set_credits { - 0 => self.set_credit_order_escrow_operations_v0( + Some(0) => self.set_credit_order_escrow_operations_v0( order_id, credits, estimated_costs_only_with_layer_info, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "set_credit_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "set_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -93,16 +101,20 @@ impl Drive { .order_escrow .remove_credits { - 0 => self.remove_credit_order_escrow_operations_v0( + Some(0) => self.remove_credit_order_escrow_operations_v0( order_id, estimated_costs_only_with_layer_info, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_credit_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "remove_credit_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -121,17 +133,21 @@ impl Drive { .order_escrow .fetch_credits { - 0 => self.fetch_credit_order_escrow_v0( + Some(0) => self.fetch_credit_order_escrow_v0( order_id, transaction, drive_operations, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "fetch_credit_order_escrow".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_credit_order_escrow".to_string(), + known_versions: vec![0], + })), } } } diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs index 39ec5bfb4c2..56956a66e20 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs @@ -22,18 +22,22 @@ impl Drive { .order_escrow .estimated_costs_for_tokens { - 0 => { + Some(0) => { Self::add_estimation_costs_for_token_order_escrow_v0( token_id, estimated_costs_only_with_layer_info, ); Ok(()) } - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_token_order_escrow".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_token_order_escrow".to_string(), + known_versions: vec![0], + })), } } @@ -49,17 +53,21 @@ impl Drive { .order_escrow .estimated_costs_for_credits { - 0 => { + Some(0) => { Self::add_estimation_costs_for_credit_order_escrow_v0( estimated_costs_only_with_layer_info, ); Ok(()) } - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_credit_order_escrow".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_credit_order_escrow".to_string(), + known_versions: vec![0], + })), } } } diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs index a4fdfe53f78..142ff4a5824 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs @@ -6,6 +6,7 @@ use crate::drive::tokens::paths::{ token_order_escrow_path, token_order_escrow_root_path, tokens_root_path, }; use crate::drive::Drive; +use crate::util::estimated_layers::insert_estimated_layer_keeping_deeper; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; @@ -17,6 +18,11 @@ use std::collections::HashMap; /// The level of the Tokens Merk the escrow key sits on. const TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS: u32 = 3; +/// The level of the prefunded specialized balances Merk the offers' escrow key sits on: its root +/// is the voting balances, 128, over the fee pots, 64 and 192, and the escrow key, 32, sits below +/// 64. +const CREDIT_ORDER_ESCROW_LEVEL_IN_PREFUNDED_BALANCES: u32 = 2; + impl Drive { /// The layers a write to one listing's escrow entry goes through: the root, the Tokens /// tree, where the escrow BigSumTree is a leaf on the third level, the escrow BigSumTree of @@ -25,7 +31,9 @@ impl Drive { token_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, ) { - estimated_costs_only_with_layer_info.insert( + // Every description below keeps a deeper one another write of the batch registered. + insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info, KeyInfoPath::from_known_path([]), EstimatedLayerInformation { tree_type: TreeType::NormalTree, @@ -36,28 +44,18 @@ impl Drive { // The escrow key sits deeper in the Tokens Merk than the balances key the token balance // estimation describes at level 0, and a batch moving tokens into or out of escrow writes - // both. One description covers the whole layer, so the deeper one is kept whichever is - // registered first: this estimation never lowers what is there. The balance estimation - // overwrites what it finds and belongs to every earlier protocol version's fees, so the - // escrow operations register theirs after it (`TokenPlaceInOrderEscrow`, - // `TokenReleaseFromOrderEscrow`). - let tokens_layer = EstimatedLayerInformation { - tree_type: TreeType::NormalTree, - estimated_layer_count: EstimatedLevel(TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS, false), - estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), - }; - match estimated_costs_only_with_layer_info - .get(&KeyInfoPath::from_known_path(tokens_root_path())) - .map(|existing| &existing.estimated_layer_count) - { - Some(EstimatedLevel(level, _)) if *level >= TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS => {} - _ => { - estimated_costs_only_with_layer_info.insert( - KeyInfoPath::from_known_path(tokens_root_path()), - tokens_layer, - ); - } - } + // both. The balance estimation overwrites what it finds and belongs to every earlier + // protocol version's fees, so the escrow operations register theirs after it + // (`TokenPlaceInOrderEscrow`, `TokenReleaseFromOrderEscrow`). + insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info, + KeyInfoPath::from_known_path(tokens_root_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(TOKEN_ORDER_ESCROW_LEVEL_IN_TOKENS, false), + estimated_layer_sizes: AllSubtrees(1, AllBigSumTrees, None), + }, + ); estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_path(token_order_escrow_root_path()), @@ -79,13 +77,16 @@ impl Drive { } /// The layers a write to one offer's escrowed credits goes through: the root, the prefunded - /// specialized balances, described as the prefunded balance and contract fee pot estimations - /// describe it so a batch writing to several of its trees gets one answer, and the SumTree of - /// one entry per open offer. + /// specialized balances, where the escrow key is a leaf on the third level, and the SumTree + /// of one entry per open offer. The prefunded balance and contract fee pot estimations + /// describe the prefunded balances layer at level 1, their keys' depth: from protocol version + /// 15 they keep a deeper description too, so a batch writing to the escrow and to one of + /// them is priced at the escrow's depth whichever registers first. pub(super) fn add_estimation_costs_for_credit_order_escrow_v0( estimated_costs_only_with_layer_info: &mut HashMap, ) { - estimated_costs_only_with_layer_info.insert( + insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info, KeyInfoPath::from_known_path([]), EstimatedLayerInformation { tree_type: TreeType::NormalTree, @@ -108,11 +109,15 @@ impl Drive { }, ); - estimated_costs_only_with_layer_info.insert( + insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info, KeyInfoPath::from_known_path(prefunded_specialized_balances_path()), EstimatedLayerInformation { tree_type: TreeType::SumTree, - estimated_layer_count: EstimatedLevel(1, false), + estimated_layer_count: EstimatedLevel( + CREDIT_ORDER_ESCROW_LEVEL_IN_PREFUNDED_BALANCES, + false, + ), estimated_layer_sizes: AllSubtrees(1, AllSumTrees, None), }, ); diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index 053a0a063ea..aeef22f2c35 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -12,11 +12,16 @@ mod tokens; #[cfg(test)] mod tests { + use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; use crate::drive::tokens::paths::tokens_root_path; use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; + use dpp::data_contract::document_type::action_fees::ContractFeePot; + use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; @@ -554,4 +559,325 @@ mod tests { .set_credit_order_escrow_operations([3; 32], largest + 1, &mut None, platform_version) .is_err()); } + + /// The escrow methods are active from protocol version 15: at 14 every one of them refuses + /// with `VersionNotActive` instead of building operations for trees that do not exist there. + #[test] + fn should_refuse_every_escrow_method_before_protocol_version_15() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let token_id = [5; 32]; + let order_id = [9; 32]; + + let calls = |platform_version: &PlatformVersion| -> Vec<(&str, Result<(), Error>)> { + let mut estimation = HashMap::new(); + vec![ + ( + "add_tokens", + drive + .add_to_token_order_escrow_operations( + token_id, + order_id, + 500, + &mut None, + None, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "set_tokens", + drive + .set_token_order_escrow_operations( + token_id, + order_id, + 200, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "remove_tokens", + drive + .remove_token_order_escrow_operations( + token_id, + order_id, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "fetch_tokens", + drive + .fetch_token_order_escrow( + token_id, + order_id, + None, + &mut vec![], + platform_version, + ) + .map(|_| ()), + ), + ( + "add_credits", + drive + .add_to_credit_order_escrow_operations( + order_id, + 70_000, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "set_credits", + drive + .set_credit_order_escrow_operations( + order_id, + 30_000, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "remove_credits", + drive + .remove_credit_order_escrow_operations( + order_id, + &mut None, + platform_version, + ) + .map(|_| ()), + ), + ( + "fetch_credits", + drive + .fetch_credit_order_escrow(order_id, None, &mut vec![], platform_version) + .map(|_| ()), + ), + ( + "estimated_costs_for_tokens", + Drive::add_estimation_costs_for_token_order_escrow( + token_id, + &mut estimation, + platform_version, + ), + ), + ( + "estimated_costs_for_credits", + Drive::add_estimation_costs_for_credit_order_escrow( + &mut estimation, + platform_version, + ), + ), + ] + }; + + let before = PlatformVersion::get(14).expect("expected protocol version 14"); + for (name, result) in calls(before) { + assert!( + matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + ), + "{name} at protocol version 14: expected VersionNotActive, got {result:?}" + ); + } + for (name, result) in calls(latest) { + assert!( + result.is_ok(), + "{name} at the latest protocol version: {result:?}" + ); + } + } + + /// What an escrow write costs, priced without state (`estimated`) and applied to the state + /// it writes to (`applied`). + fn estimated_and_applied_fees( + drive: &Drive, + build: impl Fn( + &mut Option>, + ) -> Vec, + ) -> (FeeResult, FeeResult) { + let platform_version = PlatformVersion::latest(); + let fee = |costs: Vec| { + Drive::calculate_fee( + None, + Some(costs), + &BlockInfo::default().epoch, + drive.config.epochs_per_era, + platform_version, + None, + ) + .expect("expected a fee") + }; + + let mut estimation = Some(HashMap::new()); + let operations = build(&mut estimation); + let mut estimated_costs = vec![]; + drive + .apply_batch_low_level_drive_operations( + estimation, + None, + operations, + &mut estimated_costs, + &platform_version.drive, + ) + .expect("expected to estimate"); + + let operations = build(&mut None); + let mut applied_costs = vec![]; + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut applied_costs, + &platform_version.drive, + ) + .expect("expected to apply"); + + (fee(estimated_costs), fee(applied_costs)) + } + + /// Characterization: the estimate of each escrow write covers what applying it costs on the + /// initial state, where the offers' escrow key sits on the third level of the prefunded + /// specialized balances Merk and a listing's on the fourth of the Tokens Merk. + #[test] + fn should_estimate_at_least_what_an_escrow_write_costs() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + + let (estimated, applied) = estimated_and_applied_fees(&drive, |estimation| { + drive + .add_to_credit_order_escrow_operations( + [9; 32], + 70_000, + estimation, + platform_version, + ) + .expect("expected the operations") + }); + assert!( + estimated.processing_fee >= applied.processing_fee, + "an offer's escrow: estimated {estimated:?}, applied {applied:?}" + ); + assert!(estimated.storage_fee >= applied.storage_fee); + + let (estimated, applied) = estimated_and_applied_fees(&drive, |estimation| { + drive + .set_credit_order_escrow_operations([9; 32], 30_000, estimation, platform_version) + .expect("expected the operations") + }); + assert!( + estimated.processing_fee >= applied.processing_fee, + "an offer's escrow after a fill: estimated {estimated:?}, applied {applied:?}" + ); + + let (estimated, applied) = estimated_and_applied_fees(&drive, |estimation| { + drive + .add_to_token_order_escrow_operations( + token_id, + [1; 32], + 500, + estimation, + None, + &mut None, + platform_version, + ) + .expect("expected the operations") + }); + assert!( + estimated.processing_fee >= applied.processing_fee, + "a token's first listing: estimated {estimated:?}, applied {applied:?}" + ); + assert!(estimated.storage_fee >= applied.storage_fee); + + let (estimated, applied) = estimated_and_applied_fees(&drive, |estimation| { + drive + .set_token_order_escrow_operations( + token_id, + [1; 32], + 200, + estimation, + platform_version, + ) + .expect("expected the operations") + }); + assert!( + estimated.processing_fee >= applied.processing_fee, + "a listing after a fill: estimated {estimated:?}, applied {applied:?}" + ); + } + + /// The offers' escrow is described at its key's depth in the prefunded specialized balances + /// layer, and from protocol version 15 the voting balance and fee pot estimations of the + /// same batch keep that deeper description, whichever registers first. + #[test] + fn should_keep_the_deeper_prefunded_balances_layer_estimate() { + let platform_version = PlatformVersion::latest(); + let prefunded_layer_level = + |estimation: &HashMap| match estimation + .get(&KeyInfoPath::from_known_path( + prefunded_specialized_balances_path(), + )) + .map(|layer| &layer.estimated_layer_count) + { + Some(EstimatedLevel(level, _)) => *level, + other => panic!("expected an estimated level, got {other:?}"), + }; + let voting = |estimation: &mut HashMap, + platform_version: &PlatformVersion| { + Drive::add_estimation_costs_for_prefunded_specialized_balance_update( + estimation, + &platform_version.drive, + ) + .expect("expected the voting balance estimation") + }; + let fee_pot = |estimation: &mut HashMap, + platform_version: &PlatformVersion| { + Drive::add_estimation_costs_for_contract_fee_pot_update( + ContractFeePot::Owner, + estimation, + &platform_version.drive, + ) + .expect("expected the fee pot estimation") + }; + let escrow = |estimation: &mut HashMap| { + Drive::add_estimation_costs_for_credit_order_escrow(estimation, platform_version) + .expect("expected the escrow estimation") + }; + + type Sibling = fn(&mut HashMap, &PlatformVersion); + let siblings: [Sibling; 2] = [voting, fee_pot]; + for sibling in siblings { + // The escrow first, the sibling after: the sibling keeps the escrow's depth. + let mut estimation = HashMap::new(); + escrow(&mut estimation); + assert_eq!(prefunded_layer_level(&estimation), 2); + sibling(&mut estimation, platform_version); + assert_eq!(prefunded_layer_level(&estimation), 2); + + // The sibling first, the escrow after. + let mut estimation = HashMap::new(); + sibling(&mut estimation, platform_version); + assert_eq!(prefunded_layer_level(&estimation), 1); + escrow(&mut estimation); + assert_eq!(prefunded_layer_level(&estimation), 2); + + // Protocol version 14's siblings describe the layer at their keys' level, as before. + let mut estimation = HashMap::new(); + escrow(&mut estimation); + sibling( + &mut estimation, + PlatformVersion::get(14).expect("expected protocol version 14"), + ); + assert_eq!(prefunded_layer_level(&estimation), 1); + } + } } diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs index 851858ccc3c..c3424d56e56 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs @@ -33,7 +33,7 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version.drive.methods.token.order_escrow.add_tokens { - 0 => self.add_to_token_order_escrow_operations_v0( + Some(0) => self.add_to_token_order_escrow_operations_v0( token_id, order_id, amount, @@ -42,11 +42,15 @@ impl Drive { previous_batch_operations, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_to_token_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_to_token_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -64,18 +68,22 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version.drive.methods.token.order_escrow.set_tokens { - 0 => self.set_token_order_escrow_operations_v0( + Some(0) => self.set_token_order_escrow_operations_v0( token_id, order_id, amount, estimated_costs_only_with_layer_info, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "set_token_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "set_token_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -97,17 +105,21 @@ impl Drive { .order_escrow .remove_tokens { - 0 => self.remove_token_order_escrow_operations_v0( + Some(0) => self.remove_token_order_escrow_operations_v0( token_id, order_id, estimated_costs_only_with_layer_info, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_token_order_escrow_operations".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "remove_token_order_escrow_operations".to_string(), + known_versions: vec![0], + })), } } @@ -127,18 +139,22 @@ impl Drive { .order_escrow .fetch_tokens { - 0 => self.fetch_token_order_escrow_v0( + Some(0) => self.fetch_token_order_escrow_v0( token_id, order_id, transaction, drive_operations, platform_version, ), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "fetch_token_order_escrow".to_string(), known_versions: vec![0], received: version, })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_token_order_escrow".to_string(), + known_versions: vec![0], + })), } } } diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs new file mode 100644 index 00000000000..7466afbe9d3 --- /dev/null +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -0,0 +1,122 @@ +//! Layer descriptions that several writes of one batch register for the same layer. +//! +//! A batch's cost estimation keeps one description per layer, and a layer's description gives +//! the level the written key sits on, which sets how many ancestors an update propagates +//! through. When two writes of one batch reach keys at different depths of the same layer, the +//! batch must be priced at the deeper one, whichever write registers its description first. + +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::EstimatedLevel; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +/// The level a description gives its layer, `None` for one described by its element count. +fn estimated_level(layer: &EstimatedLayerInformation) -> Option { + match layer.estimated_layer_count { + EstimatedLevel(level, _) => Some(level), + _ => None, + } +} + +/// Describes the layer at `path` as `layer`, unless the batch already describes it at a level at +/// least as deep. +pub(crate) fn insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info: &mut HashMap, + path: KeyInfoPath, + layer: EstimatedLayerInformation, +) { + if let Some(existing) = estimated_costs_only_with_layer_info.get(&path) { + if let (Some(existing_level), Some(level)) = + (estimated_level(existing), estimated_level(&layer)) + { + if existing_level >= level { + return; + } + } + } + estimated_costs_only_with_layer_info.insert(path, layer); +} + +/// Runs `register`, an estimation that overwrites the descriptions of the layers at `paths`, and +/// puts back each description it replaced that was deeper. +pub(crate) fn register_keeping_deeper_estimated_layers( + estimated_costs_only_with_layer_info: &mut HashMap, + paths: &[KeyInfoPath], + register: impl FnOnce(&mut HashMap), +) { + let before: Vec<(KeyInfoPath, EstimatedLayerInformation)> = paths + .iter() + .filter_map(|path| { + estimated_costs_only_with_layer_info + .get(path) + .map(|layer| (path.clone(), *layer)) + }) + .collect(); + register(estimated_costs_only_with_layer_info); + for (path, layer) in before { + insert_estimated_layer_keeping_deeper(estimated_costs_only_with_layer_info, path, layer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; + use grovedb::EstimatedLayerSizes::AllSubtrees; + use grovedb::EstimatedSumTrees::NoSumTrees; + use grovedb::TreeType; + + fn layer(level: u32) -> EstimatedLayerInformation { + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(level, false), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + } + } + + fn level_at( + estimation: &HashMap, + path: &KeyInfoPath, + ) -> Option { + estimation.get(path).and_then(estimated_level) + } + + #[test] + fn should_insert_unless_a_deeper_or_equal_level_is_described() { + let path = KeyInfoPath::from_known_path([[1u8].as_slice()]); + let mut estimation = HashMap::new(); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(1)); + assert_eq!(level_at(&estimation, &path), Some(1)); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(2)); + assert_eq!(level_at(&estimation, &path), Some(2)); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(1)); + assert_eq!(level_at(&estimation, &path), Some(2)); + // A description by element count is replaced as before. + estimation.insert( + path.clone(), + EstimatedLayerInformation { + estimated_layer_count: PotentiallyAtMaxElements, + ..layer(0) + }, + ); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(1)); + assert_eq!(level_at(&estimation, &path), Some(1)); + } + + #[test] + fn should_put_back_a_deeper_level_an_estimation_overwrote() { + let deep = KeyInfoPath::from_known_path([[1u8].as_slice()]); + let shallow = KeyInfoPath::from_known_path([[2u8].as_slice()]); + let mut estimation = HashMap::from([(deep.clone(), layer(2)), (shallow.clone(), layer(0))]); + register_keeping_deeper_estimated_layers( + &mut estimation, + &[deep.clone(), shallow.clone()], + |estimation| { + estimation.insert(deep.clone(), layer(1)); + estimation.insert(shallow.clone(), layer(1)); + }, + ); + assert_eq!(level_at(&estimation, &deep), Some(2)); + assert_eq!(level_at(&estimation, &shallow), Some(1)); + } +} diff --git a/packages/rs-drive/src/util/mod.rs b/packages/rs-drive/src/util/mod.rs index 4f15e99d0a6..5e59f1ebea6 100644 --- a/packages/rs-drive/src/util/mod.rs +++ b/packages/rs-drive/src/util/mod.rs @@ -11,6 +11,9 @@ pub mod object_size_info; /// Common #[cfg(any(feature = "server", feature = "verify"))] pub mod common; +/// Layer descriptions several writes of one batch register +#[cfg(feature = "server")] +pub(crate) mod estimated_layers; #[cfg(feature = "server")] mod operations; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index 0f8b135b4d2..650f5adbce7 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -1,4 +1,4 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; @@ -16,18 +16,21 @@ pub struct DriveTokenMethodVersions { pub order_escrow: DriveTokenOrderEscrowMethodVersions, } +/// The escrow methods of the token marketplace: `None` in every table before protocol version +/// 15, whose transitions are refused there, so a call before 15 is an error rather than a +/// silent write. #[derive(Clone, Debug, Default)] pub struct DriveTokenOrderEscrowMethodVersions { - pub add_tokens: FeatureVersion, - pub set_tokens: FeatureVersion, - pub remove_tokens: FeatureVersion, - pub fetch_tokens: FeatureVersion, - pub add_credits: FeatureVersion, - pub set_credits: FeatureVersion, - pub remove_credits: FeatureVersion, - pub fetch_credits: FeatureVersion, - pub estimated_costs_for_tokens: FeatureVersion, - pub estimated_costs_for_credits: FeatureVersion, + pub add_tokens: OptionalFeatureVersion, + pub set_tokens: OptionalFeatureVersion, + pub remove_tokens: OptionalFeatureVersion, + pub fetch_tokens: OptionalFeatureVersion, + pub add_credits: OptionalFeatureVersion, + pub set_credits: OptionalFeatureVersion, + pub remove_credits: OptionalFeatureVersion, + pub fetch_credits: OptionalFeatureVersion, + pub estimated_costs_for_tokens: OptionalFeatureVersion, + pub estimated_costs_for_credits: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs index 1318dfa25e4..7f3280a7831 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs @@ -70,18 +70,18 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V1: DriveTokenMethodVersions = DriveTokenM mark_once_per_identity_release_as_distributed: 0, evonode_participation_rewards: 0, }, - // The token marketplace (protocol version 15) is refused before its activation, so these - // methods are never reached by an older protocol version. + // The token marketplace's escrow is active from protocol version 15 + // (`DRIVE_TOKEN_METHOD_VERSIONS_V3`). order_escrow: DriveTokenOrderEscrowMethodVersions { - add_tokens: 0, - set_tokens: 0, - remove_tokens: 0, - fetch_tokens: 0, - add_credits: 0, - set_credits: 0, - remove_credits: 0, - fetch_credits: 0, - estimated_costs_for_tokens: 0, - estimated_costs_for_credits: 0, + add_tokens: None, + set_tokens: None, + remove_tokens: None, + fetch_tokens: None, + add_credits: None, + set_credits: None, + remove_credits: None, + fetch_credits: None, + estimated_costs_for_tokens: None, + estimated_costs_for_credits: None, }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs index 3b749789e20..84a628706b2 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs @@ -1,13 +1,28 @@ use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; -use crate::version::drive_versions::drive_token_method_versions::DriveTokenMethodVersions; +use crate::version::drive_versions::drive_token_method_versions::{ + DriveTokenMethodVersions, DriveTokenOrderEscrowMethodVersions, +}; /// Drive token methods for protocol v15+. /// /// Identical to [`super::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2`] except /// `calculate_total_tokens_balance` is bumped to `2`: the tokens the token marketplace holds in /// escrow for open listings join the identity balances and the shielded pools on the balance -/// side of the token conservation check. +/// side of the token conservation check; and the token marketplace's escrow methods, `None` +/// before, are active. pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenMethodVersions { calculate_total_tokens_balance: 2, + order_escrow: DriveTokenOrderEscrowMethodVersions { + add_tokens: Some(0), + set_tokens: Some(0), + remove_tokens: Some(0), + fetch_tokens: Some(0), + add_credits: Some(0), + set_credits: Some(0), + remove_credits: Some(0), + fetch_credits: Some(0), + estimated_costs_for_tokens: Some(0), + estimated_costs_for_credits: Some(0), + }, ..DRIVE_TOKEN_METHOD_VERSIONS_V2 }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 09e432b4e62..d6f7ad041df 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -1,7 +1,12 @@ +use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_contract_method_versions::{ + DriveContractFeePotMethodVersions, DriveContractMethodVersions, +}; use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::drive_versions::{ - DriveInitializationMethodVersions, DriveMethodVersions, DriveVersion, + DriveInitializationMethodVersions, DriveMethodVersions, + DrivePrefundedSpecializedMethodVersions, DriveVersion, }; /// Drive version 10. @@ -14,7 +19,11 @@ use crate::version::drive_versions::{ /// (`Platform::transition_to_version_15`) inserts them through the same helper. /// * **Token conservation** — `DRIVE_TOKEN_METHOD_VERSIONS_V3` bumps /// `calculate_total_tokens_balance` to 2, which counts the escrowed listings on the balance -/// side. +/// side; its escrow methods, `None` before, are active. +/// * **Escrow fee estimates** — the offers' escrow key sits a level below the other keys of the +/// prefunded specialized balances layer, so `estimated_cost_for_prefunded_specialized_balance_update` +/// 2 and `add_estimation_costs_for_contract_fee_pot_update` 1 keep a deeper description of that +/// layer another write of the batch registered, rather than lowering it to their keys' level. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -22,7 +31,18 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { initialization: DriveInitializationMethodVersions { create_initial_state_structure: 5, // changed in v10: adds the token marketplace escrow trees }, - token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow + token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow; order_escrow active + contract: DriveContractMethodVersions { + fee_pots: DriveContractFeePotMethodVersions { + add_estimation_costs_for_contract_fee_pot_update: 1, // changed in v10: keeps a deeper prefunded balances layer description + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4.fee_pots + }, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4 + }, + prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { + estimated_cost_for_prefunded_specialized_balance_update: 2, // changed in v10: keeps a deeper prefunded balances layer description + ..DRIVE_VERSION_V9.methods.prefunded_specialized_balances + }, ..DRIVE_VERSION_V9.methods }, ..DRIVE_VERSION_V9 diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 5c2a00ef53d..2fdd7e64335 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -144,8 +144,13 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`, a SumTree of the credits open offers hold. Its /// `calculate_total_tokens_balance` 2 counts the escrowed tokens on the balance side of the /// token conservation check; the credit check already counts every prefunded specialized -/// balance. `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects the token market contract v1, whose -/// `ask` and `bid` types hold the open orders: registered at genesis from this version and +/// balance. The escrow methods (`order_escrow`) are `None` in every earlier table. The offers' +/// escrow key sits two levels into the prefunded specialized balances layer, below the keys +/// the voting balance and contract fee pot estimations describe, so their v2 and v1 keep a +/// deeper description of that layer another write of the batch registered. +/// `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects the token market contract v1, whose +/// `ask` and `bid` types hold the open orders, each with the token's decimals when it was +/// placed, which prices its fills: registered at genesis from this version and /// stored without storage flags by `transition_to_version_15`. Users cannot write them: /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md index 59e6472e47c..1e4e7ecd5ed 100644 --- a/packages/token-market-contract/README.md +++ b/packages/token-market-contract/README.md @@ -21,11 +21,12 @@ Tokens a holder offers for sale at a price. | Property | Type | Meaning | | --- | --- | --- | | `tokenId` | identifier, required | The token for sale | -| `price` | integer, required | The credits the seller asks per whole token (10 to the power of the token's decimals base units) | +| `price` | integer, required | The credits the seller asks per whole token (10 to the power of `decimals` base units) | | `amount` | integer, required | The base units listed when the order was placed | | `remaining` | integer, required | The base units still for sale; the order is deleted when it is filled entirely | | `minFill` | integer, required | The smallest fill the seller accepts, unless it takes everything still open; a fill may not leave less than this open either | | `expiresAt` | integer | The block time, in milliseconds, from which the order can no longer be filled; it can still be cancelled | +| `decimals` | integer, required | The token's decimals when the order was placed. Every fill prices the order at this scale, so a later change to the token's decimals does not change what an open order costs or pays | ## `bid` @@ -41,7 +42,10 @@ the buyer's side, and `escrow`. Both types have the same three: - `book` (`tokenId`, `price`, `$createdAt`): the order book of a token, by - price and then by age. + price and then by age. Prices are per whole token at each order's own + `decimals`: after a token changes its decimals, orders placed before and + after the change are priced at different scales until the older ones close, + and a client comparing them scales each by its `decimals`. - `depth` (`tokenId`, `price`): keeps a sum per price, and over any range of prices, of what is open: the base units of the asks (`remaining`) and the credits of the bids (`escrow`). Each side sums what it holds in escrow, which diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json index 96c77ef66ea..d6ce0419395 100644 --- a/packages/token-market-contract/schema/v1/token-market-contract-documents.json +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -59,7 +59,7 @@ "price": { "type": "integer", "minimum": 1, - "description": "The credits the seller asks per whole token (10 to the power of the token's decimals base units)", + "description": "The credits the seller asks per whole token (10 to the power of the order's decimals base units)", "position": 1 }, "amount": { @@ -85,6 +85,13 @@ "minimum": 0, "description": "The block time, in milliseconds, from which the order can no longer be filled. It can still be cancelled", "position": 5 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "position": 6 } }, "required": [ @@ -93,6 +100,7 @@ "amount", "remaining", "minFill", + "decimals", "$createdAt", "$updatedAt", "$createdAtBlockHeight" @@ -159,7 +167,7 @@ "price": { "type": "integer", "minimum": 1, - "description": "The credits the buyer pays per whole token (10 to the power of the token's decimals base units)", + "description": "The credits the buyer pays per whole token (10 to the power of the order's decimals base units)", "position": 1 }, "amount": { @@ -191,6 +199,13 @@ "minimum": 0, "description": "The credits still held in escrow for the order. They cover what the open amount costs at the order's price; what is left once it is filled goes back to the buyer", "position": 6 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "position": 7 } }, "required": [ @@ -199,6 +214,7 @@ "amount", "remaining", "minFill", + "decimals", "escrow", "$createdAt", "$updatedAt", diff --git a/packages/token-market-contract/src/v1/mod.rs b/packages/token-market-contract/src/v1/mod.rs index d87e0a3f6c2..93f77355dc4 100644 --- a/packages/token-market-contract/src/v1/mod.rs +++ b/packages/token-market-contract/src/v1/mod.rs @@ -13,6 +13,7 @@ pub mod document_types { pub const REMAINING: &str = "remaining"; pub const MIN_FILL: &str = "minFill"; pub const EXPIRES_AT: &str = "expiresAt"; + pub const DECIMALS: &str = "decimals"; } pub mod indexes { @@ -35,6 +36,7 @@ pub mod document_types { pub const MIN_FILL: &str = "minFill"; pub const EXPIRES_AT: &str = "expiresAt"; pub const ESCROW: &str = "escrow"; + pub const DECIMALS: &str = "decimals"; } pub mod indexes { diff --git a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js index 91c085d1221..5b853816375 100644 --- a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js +++ b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js @@ -69,6 +69,7 @@ describe('Token Market Contract', () => { amount: 1000, remaining: 1000, minFill: 1, + decimals: 2, ...extra, }); @@ -80,7 +81,7 @@ describe('Token Market Contract', () => { expect(validate(type, raw()).isValid()).to.be.true(); }); - ['tokenId', 'price', 'amount', 'remaining', 'minFill'].forEach((property) => { + ['tokenId', 'price', 'amount', 'remaining', 'minFill', 'decimals'].forEach((property) => { it(`should require ${property}`, () => { const document = raw(); delete document[property]; @@ -100,6 +101,15 @@ describe('Token Market Contract', () => { }); }); + it('should accept decimals from 0 to 255 and refuse more', () => { + expect(validate(type, raw({ decimals: 0 })).isValid()).to.be.true(); + expect(validate(type, raw({ decimals: 255 })).isValid()).to.be.true(); + + const error = expectJsonSchemaError(validate(type, raw({ decimals: 256 }))); + + expect(error.keyword).to.equal('maximum'); + }); + it('should refuse an unknown property', () => { const error = expectJsonSchemaError(validate(type, { ...raw(), note: 'x' })); From 94ef9035e975dccfaf1c14e7ac834270f29088b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 09:40:33 +0300 Subject: [PATCH 38/62] fix(drive): keep the escrow's Tokens layer estimate whichever operation registers first From protocol version 15 the token balance estimation (for_token_balances 1) runs v0 and puts back a deeper description of the Tokens layer it replaced, as the voting balance and fee pot estimations already do for the prefunded balances layer. A batch moving tokens between a balance and escrow is priced at the escrow key's depth whichever of the two operations is built first; protocol version 14 and earlier keep v0. Co-Authored-By: Claude Opus 5.5 --- .../estimated_costs/for_token_balances/mod.rs | 10 ++- .../for_token_balances/v1/mod.rs | 29 +++++++++ .../order_escrow/estimated_costs/v0/mod.rs | 5 +- .../src/drive/tokens/order_escrow/mod.rs | 64 +++++++++++++++++++ .../src/version/drive_versions/v10.rs | 15 ++++- 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs diff --git a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/mod.rs b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/mod.rs index fcce3e3dff7..31ab383d933 100644 --- a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/mod.rs +++ b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -47,9 +48,16 @@ impl Drive { ); Ok(()) } + 1 => { + Self::add_estimation_costs_for_token_balances_v1( + token_id, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_token_balances".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs new file mode 100644 index 00000000000..3772f39d426 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs @@ -0,0 +1,29 @@ +use crate::drive::tokens::paths::tokens_root_path; +use crate::drive::Drive; +use crate::util::estimated_layers::register_keeping_deeper_estimated_layers; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds estimation costs for token balances (protocol version 15): v0, keeping a deeper + /// description of the Tokens layer that another write of the batch registered. The escrow of + /// the token marketplace's listings sits deeper in that layer than the balances key v0 + /// describes, and a batch moving tokens into or out of escrow writes both, whichever registers + /// first. + pub(super) fn add_estimation_costs_for_token_balances_v1( + token_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + register_keeping_deeper_estimated_layers( + estimated_costs_only_with_layer_info, + &[KeyInfoPath::from_known_path(tokens_root_path())], + |estimated_costs_only_with_layer_info| { + Self::add_estimation_costs_for_token_balances_v0( + token_id, + estimated_costs_only_with_layer_info, + ) + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs index 142ff4a5824..87972f82b89 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs @@ -44,9 +44,8 @@ impl Drive { // The escrow key sits deeper in the Tokens Merk than the balances key the token balance // estimation describes at level 0, and a batch moving tokens into or out of escrow writes - // both. The balance estimation overwrites what it finds and belongs to every earlier - // protocol version's fees, so the escrow operations register theirs after it - // (`TokenPlaceInOrderEscrow`, `TokenReleaseFromOrderEscrow`). + // both. From protocol version 15 the token balance estimation keeps this deeper + // description too (`for_token_balances` 1), whichever registers first. insert_estimated_layer_keeping_deeper( estimated_costs_only_with_layer_info, KeyInfoPath::from_known_path(tokens_root_path()), diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index aeef22f2c35..64d993cc06b 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -880,4 +880,68 @@ mod tests { assert_eq!(prefunded_layer_level(&estimation), 1); } } + + /// A batch moving tokens between a balance and escrow describes the Tokens layer at the + /// escrow key's depth whichever of the two operations is built first. At protocol version 14 + /// the balance estimation built last lowers it to the balances key, as it always did. + #[test] + fn should_keep_the_escrow_depth_whichever_of_balance_and_escrow_is_built_first() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_id = [5; 32]; + let tokens_layer_level = + |estimation: &Option>| match estimation + .as_ref() + .expect("expected an estimation") + .get(&KeyInfoPath::from_known_path(tokens_root_path())) + .map(|layer| &layer.estimated_layer_count) + { + Some(EstimatedLevel(level, _)) => *level, + other => panic!("expected an estimated level, got {other:?}"), + }; + let balance = |estimation: &mut Option>, + platform_version: &PlatformVersion| { + drive + .remove_from_identity_token_balance_operations( + token_id, + [7; 32], + 500, + estimation, + None, + platform_version, + ) + .expect("expected the balance operations"); + }; + let escrow = |estimation: &mut Option>| { + drive + .add_to_token_order_escrow_operations( + token_id, + [9; 32], + 500, + estimation, + None, + &mut None, + platform_version, + ) + .expect("expected the escrow operations"); + }; + + let mut estimation = Some(HashMap::new()); + balance(&mut estimation, platform_version); + escrow(&mut estimation); + assert_eq!(tokens_layer_level(&estimation), 3, "the balance first"); + + let mut estimation = Some(HashMap::new()); + escrow(&mut estimation); + balance(&mut estimation, platform_version); + assert_eq!(tokens_layer_level(&estimation), 3, "the escrow first"); + + let mut estimation = Some(HashMap::new()); + escrow(&mut estimation); + balance( + &mut estimation, + PlatformVersion::get(14).expect("expected protocol version 14"), + ); + assert_eq!(tokens_layer_level(&estimation), 0, "protocol version 14"); + } } diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index d6f7ad041df..344174baa7e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -2,6 +2,10 @@ use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CO use crate::version::drive_versions::drive_contract_method_versions::{ DriveContractFeePotMethodVersions, DriveContractMethodVersions, }; +use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_IDENTITY_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_identity_method_versions::{ + DriveIdentityCostEstimationMethodVersions, DriveIdentityMethodVersions, +}; use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::drive_versions::{ @@ -23,7 +27,9 @@ use crate::version::drive_versions::{ /// * **Escrow fee estimates** — the offers' escrow key sits a level below the other keys of the /// prefunded specialized balances layer, so `estimated_cost_for_prefunded_specialized_balance_update` /// 2 and `add_estimation_costs_for_contract_fee_pot_update` 1 keep a deeper description of that -/// layer another write of the batch registered, rather than lowering it to their keys' level. +/// layer another write of the batch registered, rather than lowering it to their keys' level; +/// likewise the listings' escrow key sits deeper in the Tokens layer than the balances key, and +/// `for_token_balances` 1 keeps a deeper description of the Tokens layer. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -39,6 +45,13 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { }, ..DRIVE_CONTRACT_METHOD_VERSIONS_V4 }, + identity: DriveIdentityMethodVersions { + cost_estimation: DriveIdentityCostEstimationMethodVersions { + for_token_balances: 1, // changed in v10: keeps a deeper Tokens layer description + ..DRIVE_IDENTITY_METHOD_VERSIONS_V2.cost_estimation + }, + ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 + }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { estimated_cost_for_prefunded_specialized_balance_update: 2, // changed in v10: keeps a deeper prefunded balances layer description ..DRIVE_VERSION_V9.methods.prefunded_specialized_balances From cd848aaf969003ed067b99fc27a65c7f377240bd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 10:11:07 +0300 Subject: [PATCH 39/62] fix(drive): keep the escrows' root estimate whichever balance operation registers first The offers' escrow sits deeper in the root tree (under the prefunded specialized balances, level 3) than the identity balances (level 1) and the token balances (level 2), and an offer's batch debits the buyer and credits the escrow. From protocol version 15 the identity balance estimation (for_balances 1, new), the token balance estimation (for_token_balances 1) and the voting balance estimation (2) keep a deeper description of the root another write of the batch registered, so the root is estimated at the escrow's level 3 whichever operation is built first. Protocol version 14 and earlier keep their estimators. Co-Authored-By: Claude Opus 5.5 --- .../estimation_costs/for_balances/mod.rs | 7 +- .../estimation_costs/for_balances/v1/mod.rs | 22 +++++++ .../v2/mod.rs | 13 ++-- .../for_token_balances/v1/mod.rs | 14 ++-- .../src/drive/tokens/order_escrow/mod.rs | 65 +++++++++++++++++++ .../src/version/drive_versions/v10.rs | 8 ++- 6 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 packages/rs-drive/src/drive/identity/estimation_costs/for_balances/v1/mod.rs diff --git a/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/mod.rs b/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/mod.rs index d4c567e33ff..a4725ee7160 100644 --- a/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/mod.rs +++ b/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -33,9 +34,13 @@ impl Drive { Self::add_estimation_costs_for_balances_v0(estimated_costs_only_with_layer_info); Ok(()) } + 1 => { + Self::add_estimation_costs_for_balances_v1(estimated_costs_only_with_layer_info); + Ok(()) + } version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_balances".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/v1/mod.rs b/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/v1/mod.rs new file mode 100644 index 00000000000..a22a4fcd234 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/estimation_costs/for_balances/v1/mod.rs @@ -0,0 +1,22 @@ +use crate::drive::Drive; +use crate::util::estimated_layers::register_keeping_deeper_estimated_layers; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds estimation costs for identity balances (protocol version 15): v0, keeping a deeper + /// description of the root layer that another write of the batch registered. An offer of the + /// token marketplace debits the buyer's balance and credits its escrow under the prefunded + /// specialized balances, which sit deeper in the root tree than the balances, whichever + /// registers first. + pub(super) fn add_estimation_costs_for_balances_v1( + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + register_keeping_deeper_estimated_layers( + estimated_costs_only_with_layer_info, + &[KeyInfoPath::from_known_path([])], + Self::add_estimation_costs_for_balances_v0, + ); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs index 8c3ec55c6bb..b8b81b141a6 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs @@ -7,18 +7,19 @@ use std::collections::HashMap; impl Drive { /// Adds estimation costs for a prefunded specialized balance update (protocol version 15): - /// v1, keeping a deeper description of the prefunded balances layer that another write of - /// the batch registered: the escrow of the token marketplace's offers sits two levels below - /// the voting balances' key in that layer. + /// v1, keeping a deeper description of the root and the prefunded balances layer that another + /// write of the batch registered: the escrow of the token marketplace's offers sits two levels + /// below the voting balances' key in that layer. #[inline(always)] pub(super) fn add_estimation_costs_for_prefunded_specialized_balance_update_v2( estimated_costs_only_with_layer_info: &mut HashMap, ) { register_keeping_deeper_estimated_layers( estimated_costs_only_with_layer_info, - &[KeyInfoPath::from_known_path( - prefunded_specialized_balances_path(), - )], + &[ + KeyInfoPath::from_known_path([]), + KeyInfoPath::from_known_path(prefunded_specialized_balances_path()), + ], Self::add_estimation_costs_for_prefunded_specialized_balance_update_v1, ); } diff --git a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs index 3772f39d426..1c09ed055f7 100644 --- a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs +++ b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs @@ -7,17 +7,21 @@ use std::collections::HashMap; impl Drive { /// Adds estimation costs for token balances (protocol version 15): v0, keeping a deeper - /// description of the Tokens layer that another write of the batch registered. The escrow of - /// the token marketplace's listings sits deeper in that layer than the balances key v0 - /// describes, and a batch moving tokens into or out of escrow writes both, whichever registers - /// first. + /// description of the root and the Tokens layer that another write of the batch registered. + /// The escrow of the token marketplace's listings sits deeper in the Tokens layer than the + /// balances key v0 describes, and the offers' escrow deeper in the root tree, under the + /// prefunded specialized balances; a batch moving tokens or credits into or out of escrow + /// writes both, whichever registers first. pub(super) fn add_estimation_costs_for_token_balances_v1( token_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, ) { register_keeping_deeper_estimated_layers( estimated_costs_only_with_layer_info, - &[KeyInfoPath::from_known_path(tokens_root_path())], + &[ + KeyInfoPath::from_known_path([]), + KeyInfoPath::from_known_path(tokens_root_path()), + ], |estimated_costs_only_with_layer_info| { Self::add_estimation_costs_for_token_balances_v0( token_id, diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index 64d993cc06b..3b2d120f334 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -944,4 +944,69 @@ mod tests { ); assert_eq!(tokens_layer_level(&estimation), 0, "protocol version 14"); } + + /// An offer's batch debits the buyer's balance and credits the offer's escrow: built in + /// either order, the estimation describes the root at the escrow's depth (3) and the + /// prefunded specialized balances layer at the escrow key's (2). At protocol version 14 the + /// balance estimation built last lowers the root to the balances' level (1), as it always + /// did. + #[test] + fn should_keep_the_credit_escrow_depths_whichever_of_debit_and_escrow_is_built_first() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let level_at = |estimation: &Option>, + path: KeyInfoPath| match estimation + .as_ref() + .expect("expected an estimation") + .get(&path) + .map(|layer| &layer.estimated_layer_count) + { + Some(EstimatedLevel(level, _)) => *level, + other => panic!("expected an estimated level, got {other:?}"), + }; + let root = || KeyInfoPath::from_known_path([]); + let prefunded = || KeyInfoPath::from_known_path(prefunded_specialized_balances_path()); + let debit = |estimation: &mut Option>, + platform_version: &PlatformVersion| { + drive + .remove_from_identity_balance_operations( + [7; 32], + 70_000, + estimation, + None, + platform_version, + ) + .expect("expected the debit operations"); + }; + let escrow = |estimation: &mut Option>| { + drive + .add_to_credit_order_escrow_operations( + [9; 32], + 70_000, + estimation, + platform_version, + ) + .expect("expected the escrow operations"); + }; + + let mut estimation = Some(HashMap::new()); + debit(&mut estimation, platform_version); + escrow(&mut estimation); + assert_eq!(level_at(&estimation, root()), 3, "the debit first"); + assert_eq!(level_at(&estimation, prefunded()), 2, "the debit first"); + + let mut estimation = Some(HashMap::new()); + escrow(&mut estimation); + debit(&mut estimation, platform_version); + assert_eq!(level_at(&estimation, root()), 3, "the escrow first"); + assert_eq!(level_at(&estimation, prefunded()), 2, "the escrow first"); + + let mut estimation = Some(HashMap::new()); + escrow(&mut estimation); + debit( + &mut estimation, + PlatformVersion::get(14).expect("expected protocol version 14"), + ); + assert_eq!(level_at(&estimation, root()), 1, "protocol version 14"); + } } diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 344174baa7e..85e532faab0 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -29,7 +29,10 @@ use crate::version::drive_versions::{ /// 2 and `add_estimation_costs_for_contract_fee_pot_update` 1 keep a deeper description of that /// layer another write of the batch registered, rather than lowering it to their keys' level; /// likewise the listings' escrow key sits deeper in the Tokens layer than the balances key, and -/// `for_token_balances` 1 keeps a deeper description of the Tokens layer. +/// `for_token_balances` 1 keeps a deeper description of the Tokens layer. The escrows also sit +/// deeper in the root tree than the identity balances and the token balances, so +/// `for_balances` 1, `for_token_balances` 1 and the voting balance estimation 2 keep a deeper +/// description of the root as well. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -47,7 +50,8 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { }, identity: DriveIdentityMethodVersions { cost_estimation: DriveIdentityCostEstimationMethodVersions { - for_token_balances: 1, // changed in v10: keeps a deeper Tokens layer description + for_balances: 1, // changed in v10: keeps a deeper root description + for_token_balances: 1, // changed in v10: keeps a deeper root and Tokens layer description ..DRIVE_IDENTITY_METHOD_VERSIONS_V2.cost_estimation }, ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 From cfe9af5933ee00bbb9764dfddb0982776eab9ff5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 11:04:31 +0300 Subject: [PATCH 40/62] fix(platform): token market review round 4 - The book and depth indexes of both order types are keyed tokenId, decimals, price, so each price scale has its own buckets and a depth sum never adds prices of two scales. A drive test sums listings at decimals 8 and 2 apart and verifies a proven per-price sum. - apply_drive_operations 2 (protocol version 15): an estimated batch lowers each operation with layer descriptions of its own and merges them keeping the deeper one, so no write of the batch (a document, a nonce) lowers what another registered, whatever their order. Applying is generation 1's. - The order bindings are tested through data_trigger_bindings_list at the latest version, running each trigger, and are absent at 14. - Genesis at 15 stores the re-keyed contract: the ten nullifier_batch_guard digests are re-pinned. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 12 +- packages/rs-dpp/src/system_data_contracts.rs | 20 ++ .../batch/data_triggers/bindings/list/mod.rs | 163 ++++++++++ .../token/shielded/nullifier_batch_guard.rs | 20 +- .../moderation/document_removal_tests.rs | 12 +- .../src/drive/tokens/order_escrow/mod.rs | 199 +++++++++++- .../apply_drive_operations/mod.rs | 11 +- .../apply_drive_operations/v1/mod.rs | 2 +- .../apply_drive_operations/v2/mod.rs | 303 ++++++++++++++++++ .../src/version/drive_versions/v10.rs | 11 +- .../rs-platform-version/src/version/v15.rs | 8 +- packages/token-market-contract/README.md | 14 +- .../v1/token-market-contract-documents.json | 12 + 13 files changed, 756 insertions(+), 31 deletions(-) create mode 100644 packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 693fa3d2834..25d52b69cae 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -59,8 +59,8 @@ offer), each with three indexes: | Index | Properties | Serves | |---|---|---| -| `book` | `tokenId`, `price`, `$createdAt` | A token's order book, by price and then by age | -| `depth` | `tokenId`, `price`, summed | The total open at each price, or over a price range, in one proven sum query: the base units of the asks (`remaining`) and the credits of the bids (`escrow`) | +| `book` | `tokenId`, `decimals`, `price`, `$createdAt` | A token's order book, by price and then by age | +| `depth` | `tokenId`, `decimals`, `price`, summed | The total open at each price, or over a price range, in one proven sum query: the base units of the asks (`remaining`) and the credits of the bids (`escrow`) | | `byOwner` | `$ownerId`, `tokenId`, `$createdAt` | An identity's open orders | Each side sums what it holds in escrow, so the sums cannot overflow: the tokens listed never exceed @@ -71,9 +71,11 @@ size. An order's price is in credits per whole token, and each order records the token's decimals when it was placed (`decimals`). Every fill prices the order at that scale, so a later change to the token's decimals (a `Conventions` update) changes neither what an open listing costs nor what an -open offer pays. The `book` and `depth` indexes order by price alone: after a token changes its -decimals, orders placed before and after the change are priced at different scales until the older -ones close, and a client comparing them scales each by its own `decimals`. +open offer pays. The `book` and `depth` indexes keep each scale apart (`tokenId`, then `decimals`, +then `price`), so a book query and a depth sum name the token's decimals and never mix prices of +different scales. After a token changes its decimals, the orders placed before the change stay +under the old decimals until they close; a client showing them alongside the new ones queries both +and scales each by its `decimals`. Only the marketplace transitions write orders. A document transition cannot create one (`creationRestrictionMode` 2), and a replace or a delete of one is refused by a data trigger. The diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index fee5d780303..ccf34356e72 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -1468,6 +1468,26 @@ mod token_market_tests { .get(ask::indexes::DEPTH) .unwrap_or_else(|| panic!("{name} has a depth index")); assert_eq!(depth.summable.as_deref(), Some(summed)); + // Prices are per whole token at each order's decimals, so the book and the depth + // sums are kept apart per scale: a query names the token and the decimals. + let index_properties = |index_name: &str| -> Vec { + document_type + .indexes() + .get(index_name) + .unwrap_or_else(|| panic!("{name} has a {index_name} index")) + .properties + .iter() + .map(|property| property.name.clone()) + .collect() + }; + assert_eq!( + index_properties(ask::indexes::DEPTH), + ["tokenId", "decimals", "price"] + ); + assert_eq!( + index_properties(ask::indexes::BOOK), + ["tokenId", "decimals", "price", "$createdAt"] + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs index e80eed9163f..7a34659aa3e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs @@ -41,3 +41,166 @@ pub fn data_trigger_bindings_list( }), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::execution::types::state_transition_execution_context::v0::StateTransitionExecutionContextV0; + use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; + use crate::execution::validation::state_transition::batch::data_triggers::bindings::data_trigger_binding::DataTriggerBindingV0Getters; + use crate::execution::validation::state_transition::batch::data_triggers::DataTriggerExecutionContext; + use crate::platform_types::platform::PlatformStateRef; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::data_trigger::DataTriggerError; + use dpp::identifier::Identifier; + use dpp::system_data_contracts::token_market_contract; + use dpp::system_data_contracts::token_market_contract::v1::document_types::{ask, bid}; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::drive::contract::DataContractFetchInfo; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::v0::DocumentDeleteTransitionActionV0; + use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::DocumentDeleteTransitionAction; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{ + DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0, + }; + use drive::state_transition_action::batch::batched_transition::document_transition::{ + DocumentTransitionAction, DocumentTransitionActionType, + }; + use std::sync::Arc; + + /// A replace or a delete of the order `document_type_name` sent as a document transition. + fn order_write( + document_type_name: &str, + action_type: DocumentTransitionActionType, + contract: Arc, + ) -> DocumentTransitionAction { + let base = DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([9; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract: contract, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + shielded_token_payment: None, + }); + match action_type { + DocumentTransitionActionType::Replace => DocumentTransitionAction::ReplaceAction( + DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base, + revision: 2, + created_at: None, + 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: Default::default(), + changed_data_fields: Default::default(), + removed_identifier_fields: Default::default(), + stored_changed_values: Default::default(), + creator_id: None, + moderated_at: None, + moderated_by: None, + property_constraint_reads: None, + }), + ), + DocumentTransitionActionType::Delete => DocumentTransitionAction::DeleteAction( + DocumentDeleteTransitionAction::V0(DocumentDeleteTransitionActionV0 { base }), + ), + other => panic!("not an order write: {other:?}"), + } + } + + /// Through the version dispatch: the latest protocol version binds exactly one trigger to + /// each replace and delete of an `ask` and a `bid`, and running it refuses the write with + /// `DataTriggerConditionError`. Protocol version 14 binds none of them. + #[test] + fn should_refuse_order_writes_through_the_latest_bindings_and_bind_none_before_15() { + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let state = platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + }; + let latest = PlatformVersion::latest(); + let contract = platform + .drive + .get_contract_with_fetch_info( + token_market_contract::ID.to_buffer(), + false, + None, + latest, + ) + .expect("expected to fetch the token market contract") + .expect("expected the token market contract at genesis"); + let order_writes = [ask::NAME, bid::NAME].into_iter().flat_map(|name| { + [ + DocumentTransitionActionType::Replace, + DocumentTransitionActionType::Delete, + ] + .into_iter() + .map(move |action_type| (name, action_type)) + }); + + let bindings = data_trigger_bindings_list(latest).expect("expected the bindings"); + for (name, action_type) in order_writes.clone() { + let matching: Vec<&DataTriggerBinding> = bindings + .iter() + .filter(|binding| { + binding.is_matching(&token_market_contract::ID, name, action_type) + }) + .collect(); + assert_eq!(matching.len(), 1, "{name} {action_type:?}"); + let mut execution_context = + StateTransitionExecutionContext::V0(StateTransitionExecutionContextV0::default()); + let block_info = BlockInfo::default(); + let owner_id = Identifier::from([7; 32]); + let mut context = DataTriggerExecutionContext { + platform: &platform_ref, + block_info: &block_info, + owner_id: &owner_id, + state_transition_execution_context: &mut execution_context, + transaction: None, + }; + let result = matching[0] + .execute( + &order_write(name, action_type, Arc::clone(&contract)), + &mut context, + latest, + ) + .expect("expected the trigger to run"); + assert!( + matches!( + result.errors.as_slice(), + [DataTriggerError::DataTriggerConditionError(_)] + ), + "{name} {action_type:?}: {:?}", + result.errors + ); + } + + let before = data_trigger_bindings_list( + PlatformVersion::get(14).expect("expected protocol version 14"), + ) + .expect("expected the bindings"); + for (name, action_type) in order_writes { + assert!( + !before.iter().any(|binding| binding.is_matching( + &token_market_contract::ID, + name, + action_type + )), + "{name} {action_type:?} is bound before 15" + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 2a46c60e4f3..dac0d899818 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "3cd7088231bf026b2002ee5fa34eddea03dd5f252a40c318c902b0b83dae8e15" + "fb45513b7e59601be0d41d7c185edb142999d47a82c590d832c2bd9bea4430a6" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "a58cd30f45254b0672572ab20dd7ad0f2c6e8d029b7ceea6da1bc0fbd4e71171", + "2567451f467275eb0b0f1627d4d5076a33c1fedff89d4a9ecdf0e58a2c749cc9", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "93e04980a29c1fc7bd691268b987ae17a871e6377f2f8e1e1dbbc959f1180df8", + "a870acb2d5a88b7a24351331450792fe3598ef49b0cbaf6e540dc39f5dc8f66c", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "8391d9d18bf6bd05b08c31c850c569e5ae6949afe50f0a7ec7c80adb76f4c7bd", + "c913de7a0bd45e7b532551d25ce6269678240ff55ab9a2c07919ff918bad6110", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "03ad65af7edfd7e963aa72868147f05302790c2a1b76647a9a67bbfd246ebea7", + "4e86bcf681b218d9412ef6e27cf178282eb0e7c21202a01a77714b9a91f570a8", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "3d47f7e8ae92b08b2ac19776f3d1549eaf49165cf2d512c02b2c997136cff866", + "3f99927bcfdd25c2381ca950daee57bc32526334268b804a65f99003d60f46aa", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "c80b00db4cec75f000fd5052e85bff5dce01f450249aa3640007b2f5c8c4f041", + "447b44557bc581708a8a7c394f2a6b5c12300b7dcb92a021102bd9c89c3d70d1", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "810a4a2a5ffa91ea87d68a901baa97c325131eaacc120c9efffba2e1c1bfe7dc", + "48ab51da4fd56c45d283520a94aa331d237c12ef6f0476f611abc108b5eed0a4", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "3101ec83ef0116744dfa36d97f22c6a32b9beca6665f3694b61794cf18532143", + "2a671dc82aea16abdee786551204b3f668ade6425ed58eb7e1cae2f0e81b79df", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "1ec71b89fba79084473b286dd1064dc6e456d1b449cdf85fde38073b75e16ee6", + "9be0c78721144addacf1791b9eda826ded336971b5f06f7e91b82b245c4d5ce8", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index b1eb5cc3ac0..8ac6133ff0a 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -1304,14 +1304,24 @@ fn should_keep_refunding_a_batch_without_the_forfeiture_before_protocol_version_ .apply_drive_operations, 0 ); + // Generation 1 (protocol version 14) introduced the forfeiture; generation 2 (15) keeps it. assert_eq!( - PlatformVersion::latest() + PlatformVersion::get(14) + .expect("expected protocol version 14") .drive .methods .batch_operations .apply_drive_operations, 1 ); + assert!( + PlatformVersion::latest() + .drive + .methods + .batch_operations + .apply_drive_operations + >= 1 + ); } #[test] diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index 3b2d120f334..f6746397a56 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -12,24 +12,37 @@ mod tokens; #[cfg(test)] mod tests { + use crate::config::{DriveConfig, DEFAULT_QUERY_LIMIT}; use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; use crate::drive::tokens::paths::tokens_root_path; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; + use crate::query::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; + use crate::query::drive_document_sum_query::{ + DocumentSumRequest, DocumentSumResponse, DriveDocumentSumQuery, SumMode, + }; + use crate::query::{WhereClause, WhereOperator}; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; 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::action_fees::ContractFeePot; + use dpp::document::{Document, DocumentV0}; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; + use dpp::platform_value::Value; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::EstimatedLevel; use grovedb::EstimatedLayerSizes::AllSubtrees; use grovedb::EstimatedSumTrees::AllBigSumTrees; - use grovedb::{EstimatedLayerInformation, TreeType}; - use std::collections::HashMap; + use grovedb::{EstimatedLayerInformation, GroveDb, TreeType}; + use std::collections::{BTreeMap, HashMap}; fn apply(drive: &Drive, operations: Vec) { drive @@ -1009,4 +1022,186 @@ mod tests { ); assert_eq!(level_at(&estimation, root()), 1, "protocol version 14"); } + + /// Orders priced at two scales sit apart in the token market contract's `depth` index + /// (`tokenId`, `decimals`, `price`): a sum for the token at decimals 8 counts only the + /// orders placed at 8 and one at decimals 2 only those at 2, though all are at the same + /// price, and a proven per-price sum at one scale verifies against the path query a client + /// rebuilds and holds that scale's orders alone. + #[test] + fn should_sum_the_depth_of_each_price_scale_apart() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = load_system_data_contract(SystemDataContract::TokenMarket, platform_version) + .expect("expected the token market contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("expected to store the token market contract"); + let ask_type = contract + .document_type_for_name("ask") + .expect("expected the ask type"); + let token_id = Identifier::from([5; 32]); + let maker_id = Identifier::from([7; 32]); + + for (i, (decimals, remaining)) in + [(8i64, 300i64), (8, 200), (2, 50)].into_iter().enumerate() + { + let document: Document = DocumentV0 { + contract_version: None, + id: Identifier::from([i as u8 + 1; 32]), + owner_id: maker_id, + properties: BTreeMap::from([ + ( + "tokenId".to_string(), + Value::Identifier(token_id.to_buffer()), + ), + ("price".to_string(), Value::I64(100)), + ("amount".to_string(), Value::I64(remaining)), + ("remaining".to_string(), Value::I64(remaining)), + ("minFill".to_string(), Value::I64(1)), + ("decimals".to_string(), Value::I64(decimals)), + ]), + revision: Some(1), + created_at: Some(1_000 + i as u64), + updated_at: Some(1_000 + i as u64), + transferred_at: None, + created_at_block_height: Some(1), + 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, + creator_id: None, + moderated_at: None, + moderated_by: None, + } + .into(); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, None)), + owner_id: Some(maker_id.to_buffer()), + }, + contract: &contract, + document_type: ask_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to store the listing"); + } + + // Every price of the token at one scale: the depth index's properties, the last one a + // range. + let at_scale = |decimals: i64| { + vec![ + WhereClause { + field: "tokenId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(token_id.to_buffer()), + }, + WhereClause { + field: "decimals".to_string(), + operator: WhereOperator::Equal, + value: Value::I64(decimals), + }, + WhereClause { + field: "price".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::I64(0), + }, + ] + }; + let drive_config = DriveConfig::default(); + let sum = |where_clauses: Vec| { + let request = DocumentSumRequest { + contract: &contract, + document_type: ask_type, + sum_property: "remaining".to_string(), + where_clauses, + order_clauses: Vec::new(), + mode: SumMode::Aggregate, + limit: None, + prove: false, + drive_config: &drive_config, + resolved_time_ranges: vec![], + }; + match drive + .execute_document_sum_request(request, None, platform_version) + .expect("expected the depth sum") + { + DocumentSumResponse::Aggregate(total) => total, + other => panic!("expected an aggregate, got {other:?}"), + } + }; + assert_eq!(sum(at_scale(8)), 500); + assert_eq!(sum(at_scale(2)), 50); + + // A proven per-price sum at decimals 8, verified as a client does. + let where_clauses = at_scale(8); + let request = DocumentSumRequest { + contract: &contract, + document_type: ask_type, + sum_property: "remaining".to_string(), + where_clauses: where_clauses.clone(), + order_clauses: Vec::new(), + mode: SumMode::GroupByRange, + limit: None, + prove: true, + drive_config: &drive_config, + resolved_time_ranges: vec![], + }; + let DocumentSumResponse::Proof(proof) = drive + .execute_document_sum_request(request, None, platform_version) + .expect("expected the proven depth sum") + else { + panic!("expected a proof"); + }; + let index = find_range_summable_index_for_where_clauses( + ask_type.indexes(), + &where_clauses, + "remaining", + &[], + ) + .expect("expected the depth index to serve the query"); + assert_eq!(index.name, "depth"); + let path_query = DriveDocumentSumQuery { + document_type: ask_type, + contract_id: contract.id().to_buffer(), + document_type_name: "ask".to_string(), + index, + where_clauses, + sum_property: "remaining".to_string(), + } + .distinct_sum_path_query(Some(DEFAULT_QUERY_LIMIT), true, platform_version) + .expect("expected the client's path query"); + let (_, elements) = + GroveDb::verify_query(&proof, &path_query, &platform_version.drive.grove_version) + .expect("expected the proof to verify"); + let sums: Vec = elements + .iter() + .filter_map(|(_, _, element)| { + element + .as_ref() + .map(|element| element.sum_value_or_default()) + }) + .collect(); + assert_eq!( + sums, + vec![500], + "one price bucket at decimals 8, holding 500" + ); + } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs index 27b98d4a88c..f90f2131499 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::util::batch::DriveOperation; @@ -64,9 +65,17 @@ impl Drive { platform_version, previous_fee_versions, ), + 2 => self.apply_drive_operations_v2( + operations, + apply, + block_info, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_drive_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index a4aaffb674f..c88352bbc27 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -262,7 +262,7 @@ impl Drive { /// for them. Given the costs of a batch in which only a moderator's deletion's document /// operations free bytes: the document's, whoever paid for it, its owner or an earlier one, and /// those of any index subtree the deletion empties, whoever created it. -fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { +pub(super) fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { for operation in cost_operations.iter_mut() { let LowLevelDriveOperation::CalculatedCostOperation(cost) = operation else { continue; diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs new file mode 100644 index 00000000000..04c37cf44d2 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -0,0 +1,303 @@ +use crate::util::batch::{DriveOperation, SystemOperationType}; + +use crate::drive::Drive; +use crate::error::{fee::FeeError, Error}; +use crate::fees::op::LowLevelDriveOperation; + +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; + +use grovedb::{EstimatedLayerInformation, TransactionArg}; + +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; + +use crate::util::batch::drive_op_batch::finalize_task::{ + DriveOperationFinalizationTasks, DriveOperationFinalizeTask, +}; +use crate::util::estimated_layers::insert_estimated_layer_keeping_deeper; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use std::collections::HashMap; + +impl Drive { + /// Generation 2 (protocol version 15): generation 1, with an estimate's layer descriptions + /// merged per operation. A batch's estimate keeps one description per layer, and in + /// generation 1 every operation's estimation writes into the same map, so the last one to + /// describe a layer sets its level: a document, a nonce or a balance write built after a + /// write deeper in the same layer (the token marketplace's escrows, in the Tokens and the + /// prefunded specialized balances trees) lowered the batch's estimate of that layer. Here + /// each operation is lowered with a map of its own, merged into the batch's keeping the + /// deeper of two descriptions of one layer, so the estimate does not depend on the order of + /// the batch's operations. Applying a batch is generation 1's. + #[inline(always)] + pub(crate) fn apply_drive_operations_v2( + &self, + operations: Vec, + apply: bool, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + DriveOperation::refuse_repeated_token_balance_writes(&operations)?; + self.refuse_repeated_counter_moves(&operations, block_info, transaction, platform_version)?; + let mut operations = DriveOperation::merge_balance_writes(operations)?; + let mut processing_pool_credits: Credits = 0; + for operation in operations.extract_if(.., |operation| { + matches!( + operation, + DriveOperation::SystemOperation( + SystemOperationType::AddToCurrentEpochProcessingPool { .. } + ) + ) + }) { + if let DriveOperation::SystemOperation( + SystemOperationType::AddToCurrentEpochProcessingPool { amount }, + ) = operation + { + processing_pool_credits = + processing_pool_credits + .checked_add(amount) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow when accumulating processing pool credits", + )))?; + } + } + if operations.is_empty() && (!apply || processing_pool_credits == 0) { + return Ok(FeeResult::default()); + } + let forfeits_storage_refunds = operations + .iter() + .any(DriveOperation::forfeits_storage_refunds); + // The document operations of a moderator's deletion go in a batch of their own only + // when something else in the batch may free bytes someone is owed. + let separates_forfeited_operations = forfeits_storage_refunds + && operations + .iter() + .any(DriveOperation::refunds_moderation_storage); + // With no caller transaction, TTL preparation (direct drainage + // writes), conversion reads, and the batch apply would each commit + // on their own, so a conversion error after preparation would leave + // drained buckets committed without the write. Span all of it with + // one owned transaction and commit only once the batch applied. + let caller_transaction = transaction; + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + if apply { + self.prepare_drive_operations_time_range_ttl( + &operations, + block_info, + transaction, + platform_version, + )?; + } + let mut low_level_operations = vec![]; + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + // The document operations of a moderator's deletion, whose removals refund nobody + let mut forfeited_low_level_operations = vec![]; + + let mut finalize_tasks: Vec = Vec::new(); + + for drive_op in operations { + if let Some(tasks) = drive_op.finalization_tasks(platform_version)? { + finalize_tasks.extend(tasks); + } + + let forfeited = separates_forfeited_operations + && matches!(drive_op, DriveOperation::DocumentOperation(_)); + // An estimate lowers each operation into layer descriptions of its own and merges + // them into the batch's, keeping the deeper of two descriptions of one layer: what + // an operation registers never lowers what another registered, whichever comes + // first. + let mut converted = match estimated_costs_only_with_layer_info.as_mut() { + None => drive_op.into_low_level_drive_operations_after_ttl_drain( + self, + &mut None, + block_info, + transaction, + platform_version, + )?, + Some(batch_layers) => { + let mut operation_layers = Some(HashMap::new()); + let converted = drive_op.into_low_level_drive_operations_after_ttl_drain( + self, + &mut operation_layers, + block_info, + transaction, + platform_version, + )?; + for (path, layer) in operation_layers.unwrap_or_default() { + insert_estimated_layer_keeping_deeper(batch_layers, path, layer); + } + converted + } + }; + if forfeited { + forfeited_low_level_operations.append(&mut converted); + } else { + low_level_operations.append(&mut converted); + } + } + + let repaid_identity_debt = + LowLevelDriveOperation::take_repaid_identity_debt(&mut low_level_operations)?; + + let mut cost_operations = vec![]; + + let forfeited_estimated_costs_only_with_layer_info = + if forfeited_low_level_operations.is_empty() { + None + } else { + Some(estimated_costs_only_with_layer_info.clone()) + }; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + low_level_operations, + &mut cost_operations, + &platform_version.drive, + )?; + if let Some(estimated_costs_only_with_layer_info) = + forfeited_estimated_costs_only_with_layer_info + { + let mut forfeited_cost_operations = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + forfeited_low_level_operations, + &mut forfeited_cost_operations, + &platform_version.drive, + )?; + super::v1::forfeit_storage_refunds(&mut forfeited_cost_operations); + cost_operations.append(&mut forfeited_cost_operations); + } else if forfeits_storage_refunds { + // One batch, in which only the document operations free bytes: it forfeits whole. + super::v1::forfeit_storage_refunds(&mut cost_operations); + } + self.apply_repaid_identity_debt_to_processing_pool( + repaid_identity_debt, + &block_info.epoch, + transaction, + platform_version, + )?; + // Read the pool after ordinary writes and debt repayment so neither can overwrite + // the returned allocation. This transfer shares their transaction and is not billed. + if apply && processing_pool_credits > 0 { + let pool_operation = self.add_epoch_processing_credits_for_distribution_operation( + &block_info.epoch, + processing_pool_credits, + transaction, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + transaction, + vec![pool_operation], + &mut vec![], + &platform_version.drive, + )?; + } + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + // Execute drive operation callbacks after updating state. Nothing was written when + // only estimating, so there is nothing to finalize. The tasks read through the + // caller's transaction; an owned one was committed just above, and `caller_transaction` + // is `None` exactly then, so they read committed state. + if apply { + for task in finalize_tasks { + task.execute(self, caller_transaction, platform_version)?; + } + } + + Drive::calculate_fee( + None, + Some(cost_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::util::batch::drive_op_batch::PrefundedSpecializedBalanceOperationType; + use crate::util::batch::{DriveOperation, IdentityOperationType}; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::fee::fee_result::FeeResult; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + /// A write that describes the root layer shallowly (an identity contract nonce, level 0) + /// and one that describes it deeper (a prefunded specialized balance, level 3). + fn shallow() -> DriveOperation<'static> { + DriveOperation::IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: [1; 32], + contract_id: [2; 32], + nonce: 3, + }) + } + + fn deep() -> DriveOperation<'static> { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewPrefundedBalance { + prefunded_specialized_balance_id: Identifier::from([4; 32]), + add_balance: 1_000, + }, + ) + } + + fn estimate( + drive: &Drive, + operations: Vec>, + platform_version: &PlatformVersion, + ) -> FeeResult { + drive + .apply_drive_operations( + operations, + false, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("expected an estimate") + } + + /// From protocol version 15 a batch's estimate does not depend on the order of its + /// operations: built either way, the shallow and the deep write are estimated alike, at the + /// deeper root. Generation 1 (protocol version 14) estimated the batch by whichever write + /// came last, so the two orders differed. + #[test] + fn should_estimate_a_batch_alike_in_either_order() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let shallow_first = estimate(&drive, vec![shallow(), deep()], latest); + let deep_first = estimate(&drive, vec![deep(), shallow()], latest); + assert_eq!(shallow_first, deep_first); + + let before = PlatformVersion::get(14).expect("expected protocol version 14"); + let shallow_first_before = estimate(&drive, vec![shallow(), deep()], before); + let deep_first_before = estimate(&drive, vec![deep(), shallow()], before); + assert_ne!(shallow_first_before, deep_first_before); + // The order-independent estimate is the conservative one of the two. + assert!( + shallow_first.processing_fee + >= shallow_first_before + .processing_fee + .max(deep_first_before.processing_fee) + ); + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 85e532faab0..8c2456db492 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -9,7 +9,7 @@ use crate::version::drive_versions::drive_identity_method_versions::{ use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::drive_versions::{ - DriveInitializationMethodVersions, DriveMethodVersions, + DriveBatchOperationsMethodVersion, DriveInitializationMethodVersions, DriveMethodVersions, DrivePrefundedSpecializedMethodVersions, DriveVersion, }; @@ -32,7 +32,10 @@ use crate::version::drive_versions::{ /// `for_token_balances` 1 keeps a deeper description of the Tokens layer. The escrows also sit /// deeper in the root tree than the identity balances and the token balances, so /// `for_balances` 1, `for_token_balances` 1 and the voting balance estimation 2 keep a deeper -/// description of the root as well. +/// description of the root as well. Beyond the estimators, `apply_drive_operations` 2 lowers +/// each operation of an estimated batch with layer descriptions of its own and merges them +/// keeping the deeper one, so no other write of the batch (a document, a nonce) lowers what +/// the escrows describe, whichever is built first. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -56,6 +59,10 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { }, ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, + batch_operations: DriveBatchOperationsMethodVersion { + apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions keeping the deeper + ..DRIVE_VERSION_V9.methods.batch_operations + }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { estimated_cost_for_prefunded_specialized_balance_update: 2, // changed in v10: keeps a deeper prefunded balances layer description ..DRIVE_VERSION_V9.methods.prefunded_specialized_balances diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 2fdd7e64335..301c213308c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -147,10 +147,14 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// balance. The escrow methods (`order_escrow`) are `None` in every earlier table. The offers' /// escrow key sits two levels into the prefunded specialized balances layer, below the keys /// the voting balance and contract fee pot estimations describe, so their v2 and v1 keep a -/// deeper description of that layer another write of the batch registered. +/// deeper description of that layer another write of the batch registered; and +/// `apply_drive_operations` 2 merges each operation's estimated layer descriptions into the +/// batch's keeping the deeper one, so an estimate does not depend on the operations' order. /// `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects the token market contract v1, whose /// `ask` and `bid` types hold the open orders, each with the token's decimals when it was -/// placed, which prices its fills: registered at genesis from this version and +/// placed, which prices its fills and keys its `book` and `depth` index entries +/// (`tokenId`, `decimals`, `price`) so prices of different scales never mix: registered at +/// genesis from this version and /// stored without storage flags by `transition_to_version_15`. Users cannot write them: /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md index 1e4e7ecd5ed..db89f6b6e61 100644 --- a/packages/token-market-contract/README.md +++ b/packages/token-market-contract/README.md @@ -41,13 +41,13 @@ the buyer's side, and `escrow`. Both types have the same three: -- `book` (`tokenId`, `price`, `$createdAt`): the order book of a token, by - price and then by age. Prices are per whole token at each order's own - `decimals`: after a token changes its decimals, orders placed before and - after the change are priced at different scales until the older ones close, - and a client comparing them scales each by its `decimals`. -- `depth` (`tokenId`, `price`): keeps a sum per price, and over any range of - prices, of what is open: the base units of the asks (`remaining`) and the +- `book` (`tokenId`, `decimals`, `price`, `$createdAt`): the order book of a + token, by price and then by age, for one scale of price. Prices are per whole + token at each order's own `decimals`, so a query names the token's current + decimals; after a token changes its decimals, the orders placed before the + change sit apart, under the old decimals, until they close. +- `depth` (`tokenId`, `decimals`, `price`): keeps a sum per price, and over any + range of prices at one scale, of what is open: the base units of the asks (`remaining`) and the credits of the bids (`escrow`). Each side sums what it holds in escrow, which the token's supply bounds for asks and the credits on Platform bound for bids, so the sums cannot overflow. diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json index d6ce0419395..edab7559b3f 100644 --- a/packages/token-market-contract/schema/v1/token-market-contract-documents.json +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -10,6 +10,9 @@ { "tokenId": "asc" }, + { + "decimals": "asc" + }, { "price": "asc" }, @@ -24,6 +27,9 @@ { "tokenId": "asc" }, + { + "decimals": "asc" + }, { "price": "asc" } @@ -118,6 +124,9 @@ { "tokenId": "asc" }, + { + "decimals": "asc" + }, { "price": "asc" }, @@ -132,6 +141,9 @@ { "tokenId": "asc" }, + { + "decimals": "asc" + }, { "price": "asc" } From 90918f4787b076799c68c81de09538ff38c92c2a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 11:53:01 +0300 Subject: [PATCH 41/62] fix(drive): merge two estimates of one layer at one depth by their cost apply_drive_operations 2 merged each operation's layer descriptions keeping the deeper, and at one depth the first registered, so two operations describing a shared layer at one depth with different sizes (a token mint and a prefunded balance write at the root) were estimated by whichever came first. merge_estimated_layer keeps the costlier: the deeper by estimate_levels, then at one depth the one pricing a larger node (average key plus value with feature and flags), then a fixed tie-break, so the batch's estimate is the same in either order. Co-Authored-By: Claude Opus 5.5 --- .../apply_drive_operations/v2/mod.rs | 46 +++++-- .../rs-drive/src/util/estimated_layers.rs | 116 ++++++++++++++++++ 2 files changed, 154 insertions(+), 8 deletions(-) diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs index 04c37cf44d2..428bd8799ca 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -16,7 +16,7 @@ use grovedb::batch::KeyInfoPath; use crate::util::batch::drive_op_batch::finalize_task::{ DriveOperationFinalizationTasks, DriveOperationFinalizeTask, }; -use crate::util::estimated_layers::insert_estimated_layer_keeping_deeper; +use crate::util::estimated_layers::merge_estimated_layer; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use std::collections::HashMap; @@ -28,8 +28,9 @@ impl Drive { /// write deeper in the same layer (the token marketplace's escrows, in the Tokens and the /// prefunded specialized balances trees) lowered the batch's estimate of that layer. Here /// each operation is lowered with a map of its own, merged into the batch's keeping the - /// deeper of two descriptions of one layer, so the estimate does not depend on the order of - /// the batch's operations. Applying a batch is generation 1's. + /// costlier of two descriptions of one layer (`merge_estimated_layer`: the deeper, then at + /// one depth the one pricing a larger node), so the estimate does not depend on the order + /// of the batch's operations. Applying a batch is generation 1's. #[inline(always)] pub(crate) fn apply_drive_operations_v2( &self, @@ -113,9 +114,9 @@ impl Drive { let forfeited = separates_forfeited_operations && matches!(drive_op, DriveOperation::DocumentOperation(_)); // An estimate lowers each operation into layer descriptions of its own and merges - // them into the batch's, keeping the deeper of two descriptions of one layer: what - // an operation registers never lowers what another registered, whichever comes - // first. + // them into the batch's, keeping the costlier of two descriptions of one layer (the + // deeper, then at one depth the larger node): the batch's estimate is the same + // whichever order its operations come in. let mut converted = match estimated_costs_only_with_layer_info.as_mut() { None => drive_op.into_low_level_drive_operations_after_ttl_drain( self, @@ -134,7 +135,12 @@ impl Drive { platform_version, )?; for (path, layer) in operation_layers.unwrap_or_default() { - insert_estimated_layer_keeping_deeper(batch_layers, path, layer); + merge_estimated_layer( + batch_layers, + path, + layer, + &platform_version.drive.grove_version, + )?; } converted } @@ -232,7 +238,9 @@ impl Drive { #[cfg(test)] mod tests { use crate::drive::Drive; - use crate::util::batch::drive_op_batch::PrefundedSpecializedBalanceOperationType; + use crate::util::batch::drive_op_batch::{ + PrefundedSpecializedBalanceOperationType, TokenOperationType, + }; use crate::util::batch::{DriveOperation, IdentityOperationType}; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -276,6 +284,28 @@ mod tests { .expect("expected an estimate") } + /// A token mint and a prefunded balance write both describe the root at depth 3, with + /// different key sizes and sum trees: the batch's estimate keeps the costlier of the two, + /// whichever comes first. + #[test] + fn should_estimate_two_descriptions_of_one_depth_alike_in_either_order() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let mint = || { + DriveOperation::TokenOperation(TokenOperationType::TokenMint { + token_id: Identifier::from([5; 32]), + identity_balance_holder_id: Identifier::from([6; 32]), + mint_amount: 100, + allow_first_mint: true, + allow_saturation: false, + }) + }; + assert_eq!( + estimate(&drive, vec![mint(), deep()], latest), + estimate(&drive, vec![deep(), mint()], latest) + ); + } + /// From protocol version 15 a batch's estimate does not depend on the order of its /// operations: built either way, the shallow and the deep write are estimated alike, at the /// deeper root. Generation 1 (protocol version 14) estimated the batch by whichever write diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 7466afbe9d3..353397a0925 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -5,9 +5,13 @@ //! through. When two writes of one batch reach keys at different depths of the same layer, the //! batch must be priced at the deeper one, whichever write registers its description first. +use crate::error::Error; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::EstimatedLevel; use grovedb::EstimatedLayerInformation; +use grovedb::EstimatedLayerSizes; +use grovedb_version::version::GroveVersion; +use std::cmp::Ordering; use std::collections::HashMap; /// The level a description gives its layer, `None` for one described by its element count. @@ -37,6 +41,78 @@ pub(crate) fn insert_estimated_layer_keeping_deeper( estimated_costs_only_with_layer_info.insert(path, layer); } +/// The bytes the estimation prices one node of a layer described as `layer` with: its average key +/// and its value with feature and flags, the larger of each part of a mixed layer. +fn node_bytes( + layer: &EstimatedLayerInformation, + grove_version: &GroveVersion, +) -> Result { + let key = match &layer.estimated_layer_sizes { + EstimatedLayerSizes::AllSubtrees(key, _, _) + | EstimatedLayerSizes::AllItems(key, _, _) + | EstimatedLayerSizes::AllReference(key, _, _) + | EstimatedLayerSizes::AllItemsWithSumItem(key, _, _) + | EstimatedLayerSizes::AllReferencesWithSumItem(key, _, _) => *key, + EstimatedLayerSizes::Mix { + subtrees_size, + items_size, + references_size, + items_with_sum_item_size, + references_with_sum_item_size, + } => [ + subtrees_size.map(|(key, ..)| key), + items_size.map(|(key, ..)| key), + references_size.map(|(key, ..)| key), + items_with_sum_item_size.map(|(key, ..)| key), + references_with_sum_item_size.map(|(key, ..)| key), + ] + .into_iter() + .flatten() + .max() + .unwrap_or_default(), + }; + let value = layer + .estimated_layer_sizes + .value_with_feature_and_flags_size(grove_version) + .map_err(|error| Error::GroveDB(Box::new(error.into())))?; + Ok(u32::from(key) + value) +} + +/// Orders two descriptions of one layer by what they price an update through it at: the +/// deeper first (`estimate_levels`, which gives a description by element count a depth too), +/// then at one depth the larger node, then, for two that price alike, by their contents, so +/// the order is total and the same whichever is seen first. +fn compare_layer_cost( + a: &EstimatedLayerInformation, + b: &EstimatedLayerInformation, + grove_version: &GroveVersion, +) -> Result { + Ok(a.estimated_layer_count + .estimate_levels() + .cmp(&b.estimated_layer_count.estimate_levels()) + .then(node_bytes(a, grove_version)?.cmp(&node_bytes(b, grove_version)?)) + .then_with(|| format!("{a:?}").cmp(&format!("{b:?}")))) +} + +/// Merges `layer` into the batch's description of the layer at `path`, keeping the costlier of +/// the two ([`compare_layer_cost`]). The order two operations of a batch register their +/// descriptions of a shared layer in no longer matters: the batch's estimate keeps the deeper, +/// and of two at one depth the one pricing a larger node. +pub(crate) fn merge_estimated_layer( + estimated_costs_only_with_layer_info: &mut HashMap, + path: KeyInfoPath, + layer: EstimatedLayerInformation, + grove_version: &GroveVersion, +) -> Result<(), Error> { + if let Some(existing) = estimated_costs_only_with_layer_info.get(&path) { + if compare_layer_cost(existing, &layer, grove_version)? != Ordering::Less { + return Ok(()); + } + } + estimated_costs_only_with_layer_info.insert(path, layer); + Ok(()) +} + /// Runs `register`, an estimation that overwrites the descriptions of the layers at `paths`, and /// puts back each description it replaced that was deeper. pub(crate) fn register_keeping_deeper_estimated_layers( @@ -103,6 +179,46 @@ mod tests { assert_eq!(level_at(&estimation, &path), Some(1)); } + /// Two descriptions of one layer at one depth that price a node differently merge to the + /// costlier, in either order; a deeper one wins whatever its node; and two that price alike + /// merge to the same one in either order. + #[test] + fn should_merge_two_descriptions_of_a_layer_alike_in_either_order() { + let grove_version = GroveVersion::latest(); + let path = KeyInfoPath::from_known_path([[1u8].as_slice()]); + let wide_key = EstimatedLayerInformation { + estimated_layer_sizes: AllSubtrees(17, NoSumTrees, None), + ..layer(3) + }; + let narrow_key = EstimatedLayerInformation { + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + ..layer(3) + }; + let merged = |first: EstimatedLayerInformation, second: EstimatedLayerInformation| { + let mut estimation = HashMap::new(); + merge_estimated_layer(&mut estimation, path.clone(), first, grove_version) + .expect("expected to merge"); + merge_estimated_layer(&mut estimation, path.clone(), second, grove_version) + .expect("expected to merge"); + estimation[&path] + }; + assert_eq!(merged(wide_key, narrow_key), wide_key); + assert_eq!(merged(narrow_key, wide_key), wide_key); + + let deeper_narrow = EstimatedLayerInformation { + estimated_layer_count: EstimatedLevel(4, false), + ..narrow_key + }; + assert_eq!(merged(wide_key, deeper_narrow), deeper_narrow); + assert_eq!(merged(deeper_narrow, wide_key), deeper_narrow); + + let flagged = EstimatedLayerInformation { + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, Some(16)), + ..layer(3) + }; + assert_eq!(merged(narrow_key, flagged), merged(flagged, narrow_key)); + } + #[test] fn should_put_back_a_deeper_level_an_estimation_overwrote() { let deep = KeyInfoPath::from_known_path([[1u8].as_slice()]); From abbd7e859659d9835ceddda4128e8c320beb2010 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 12:51:32 +0300 Subject: [PATCH 42/62] fix(drive): price equal-depth layer descriptions with GroveDB's estimator - merge_estimated_layer compares two descriptions of one layer at one depth by what GroveDB's own average_case_merk_propagate charges for each, in credits at the version's fees, instead of a key-plus-value size of Drive's own: AllItems(32, 8) (254,960 credits) now beats AllSubtrees(32, AllBigSumTrees) (221,360). Depth still comes first, and a fixed tie-break settles equal prices. - The merge test pins both prices and exercises the tie-break with two descriptions GroveDB prices alike. - The generation 1 dispatcher tests run at protocol version 14 and at the latest version, so both generations keep their coverage. Co-Authored-By: Claude Opus 5.5 --- .../apply_drive_operations/v1/mod.rs | 501 +++++++++--------- .../apply_drive_operations/v2/mod.rs | 15 +- .../rs-drive/src/util/estimated_layers.rs | 130 +++-- 3 files changed, 327 insertions(+), 319 deletions(-) diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index c88352bbc27..8850d27fdb4 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -293,6 +293,18 @@ mod tests { use grovedb_costs::OperationCost; use intmap::IntMap; + /// The protocol versions of the generations that apply a batch as this one does: 14 runs + /// generation 1, and the latest generation 2, which builds on it. The tests through the + /// dispatcher run at both, so each generation keeps its coverage. + fn generations() -> [&'static PlatformVersion; 2] { + let pv14 = PlatformVersion::get(14).expect("expected protocol version 14"); + assert_eq!( + pv14.drive.methods.batch_operations.apply_drive_operations, + 1 + ); + [pv14, PlatformVersion::latest()] + } + fn pool_credit(amount: Credits) -> DriveOperation<'static> { DriveOperation::SystemOperation(SystemOperationType::AddToCurrentEpochProcessingPool { amount, @@ -363,230 +375,98 @@ mod tests { #[test] fn should_apply_processing_pool_credits_without_ordinary_operations_in_the_same_transaction() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(None); - let block_info = debt_test_block_info(); - let transaction = drive.grove.start_transaction(); - let fees = drive - .apply_drive_operations( - vec![pool_credit(11), pool_credit(13)], - true, - &block_info, - Some(&transaction), - platform_version, - None, - ) - .unwrap(); - assert_eq!( - fees, - FeeResult::default(), - "internal pool returns are not billed" - ); - assert_eq!( - drive - .get_epoch_processing_credits_for_distribution( - &block_info.epoch, - Some(&transaction), - platform_version - ) - .unwrap(), - 24 - ); - assert_eq!( - processing_pool(&drive, platform_version), - 0, - "caller transaction is not committed" - ); - drive - .commit_transaction(transaction, &platform_version.drive) - .unwrap(); - drive - .apply_drive_operations( - vec![pool_credit(7)], - true, - &block_info, - None, - platform_version, - None, - ) - .unwrap(); - assert_eq!(processing_pool(&drive, platform_version), 31); - } - - #[test] - fn should_add_processing_pool_returns_after_ordinary_pool_writes_and_debt_repayment() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(None); - let block_info = debt_test_block_info(); - let recipient = indebted_identity(&drive, [7; 32], 7, platform_version); - let pool_write = block_info - .epoch - .update_processing_fee_pool_operation(110) - .unwrap(); - let batch = vec![ - pool_credit(13), - DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { - identity_id: recipient, - added_balance: 20, - }), - pool_credit(11), - DriveOperation::GroveDBOpBatch(GroveDbOpBatch::from_operations(vec![pool_write])), - ]; - drive - .apply_drive_operations(batch, true, &block_info, None, platform_version, None) - .unwrap(); - assert_eq!(balance(&drive, recipient, platform_version), 13); - assert_eq!(debt(&drive, recipient, platform_version), 0); - assert_eq!(processing_pool(&drive, platform_version), 110 + 7 + 24); - } - - #[test] - fn should_estimate_processing_pool_returns_without_reading_or_writing_the_pool() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(None); - let block_info = debt_test_block_info(); - let recipient = indebted_identity(&drive, [7; 32], 7, platform_version); - // An invalid pool would reject an applied return. An estimate must never read it. - drive - .grove - .insert( - &block_info.epoch.get_path(), - KEY_POOL_PROCESSING_FEES, - Element::new_item(vec![1]), - None, - None, - &platform_version.drive.grove_version, - ) - .unwrap() - .unwrap(); - let before = drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .unwrap(); - let ordinary = - DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { - identity_id: recipient, - added_balance: 20, - }); - let expected = drive - .apply_drive_operations( - vec![ordinary.clone()], - false, - &block_info, - None, - platform_version, - None, - ) - .unwrap(); - let actual = drive - .apply_drive_operations( - vec![pool_credit(24), ordinary], - false, - &block_info, - None, - platform_version, - None, - ) - .unwrap(); - assert_eq!(actual, expected); - assert_eq!( - drive + for platform_version in generations() { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let block_info = debt_test_block_info(); + let transaction = drive.grove.start_transaction(); + let fees = drive .apply_drive_operations( - vec![pool_credit(24)], - false, + vec![pool_credit(11), pool_credit(13)], + true, &block_info, - None, + Some(&transaction), platform_version, - None + None, ) - .unwrap(), - FeeResult::default() - ); - assert_eq!( + .unwrap(); + assert_eq!( + fees, + FeeResult::default(), + "internal pool returns are not billed" + ); + assert_eq!( + drive + .get_epoch_processing_credits_for_distribution( + &block_info.epoch, + Some(&transaction), + platform_version + ) + .unwrap(), + 24 + ); + assert_eq!( + processing_pool(&drive, platform_version), + 0, + "caller transaction is not committed" + ); + drive + .commit_transaction(transaction, &platform_version.drive) + .unwrap(); drive .apply_drive_operations( - vec![pool_credit(0)], + vec![pool_credit(7)], true, &block_info, None, platform_version, - None + None, ) - .unwrap(), - FeeResult::default() - ); - assert_eq!( - drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .unwrap(), - before - ); - assert_eq!(balance(&drive, recipient, platform_version), 0); - assert_eq!(debt(&drive, recipient, platform_version), 7); + .unwrap(); + assert_eq!(processing_pool(&drive, platform_version), 31); + } } #[test] - fn should_refuse_processing_pool_credit_accumulation_overflow_before_writing() { - let platform_version = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(None); - let recipient = indebted_identity(&drive, [7; 32], 0, platform_version); - let before = drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .unwrap(); - for apply in [false, true] { - let result = drive.apply_drive_operations( - vec![ - DriveOperation::IdentityOperation( - IdentityOperationType::AddToIdentityBalance { - identity_id: recipient, - added_balance: 9, - }, - ), - pool_credit(u64::MAX), - pool_credit(1), - ], - apply, - &debt_test_block_info(), - None, - platform_version, - None, - ); - assert!(matches!(result, Err(Error::Fee(FeeError::Overflow(_))))); - assert_eq!( - drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .unwrap(), - before - ); + fn should_add_processing_pool_returns_after_ordinary_pool_writes_and_debt_repayment() { + for platform_version in generations() { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let block_info = debt_test_block_info(); + let recipient = indebted_identity(&drive, [7; 32], 7, platform_version); + let pool_write = block_info + .epoch + .update_processing_fee_pool_operation(110) + .unwrap(); + let batch = vec![ + pool_credit(13), + DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id: recipient, + added_balance: 20, + }), + pool_credit(11), + DriveOperation::GroveDBOpBatch(GroveDbOpBatch::from_operations(vec![pool_write])), + ]; + drive + .apply_drive_operations(batch, true, &block_info, None, platform_version, None) + .unwrap(); + assert_eq!(balance(&drive, recipient, platform_version), 13); + assert_eq!(debt(&drive, recipient, platform_version), 0); + assert_eq!(processing_pool(&drive, platform_version), 110 + 7 + 24); } - assert_eq!(balance(&drive, recipient, platform_version), 0); } #[test] - fn should_roll_back_owned_transaction_when_a_post_batch_processing_pool_return_fails() { - let platform_version = PlatformVersion::latest(); - for corrupted in [true, false] { - let drive = setup_drive_with_initial_state_structure(None); + fn should_estimate_processing_pool_returns_without_reading_or_writing_the_pool() { + for platform_version in generations() { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); let block_info = debt_test_block_info(); - let recipient = indebted_identity(&drive, [7; 32], 0, platform_version); - let pool = if corrupted { - Element::new_item(vec![1]) - } else { - Element::new_sum_item(i64::MAX) - }; + let recipient = indebted_identity(&drive, [7; 32], 7, platform_version); + // An invalid pool would reject an applied return. An estimate must never read it. drive .grove .insert( &block_info.epoch.get_path(), KEY_POOL_PROCESSING_FEES, - pool, + Element::new_item(vec![1]), None, None, &platform_version.drive.grove_version, @@ -598,57 +478,58 @@ mod tests { .root_hash(None, &platform_version.drive.grove_version) .unwrap() .unwrap(); - let batch = vec![ + let ordinary = DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { identity_id: recipient, - added_balance: 9, - }), - pool_credit(1), - ]; - let result = drive.apply_drive_operations( - batch.clone(), - true, - &block_info, - None, - platform_version, - None, - ); - if corrupted { - assert!(matches!( - result, - Err(Error::Drive(DriveError::UnexpectedElementType(_))) - )); - } else { - assert!(matches!(result, Err(Error::Protocol(_)))); - } - assert_eq!( - drive - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .unwrap(), - before - ); - assert_eq!(balance(&drive, recipient, platform_version), 0); - // A caller-owned transaction retains writes until its caller rolls back/drops it. - let transaction = drive.grove.start_transaction(); - assert!(drive + added_balance: 20, + }); + let expected = drive .apply_drive_operations( - batch, - true, + vec![ordinary.clone()], + false, &block_info, - Some(&transaction), + None, + platform_version, + None, + ) + .unwrap(); + let actual = drive + .apply_drive_operations( + vec![pool_credit(24), ordinary], + false, + &block_info, + None, platform_version, - None + None, ) - .is_err()); + .unwrap(); + assert_eq!(actual, expected); assert_eq!( drive - .fetch_identity_balance(recipient, Some(&transaction), platform_version) + .apply_drive_operations( + vec![pool_credit(24)], + false, + &block_info, + None, + platform_version, + None + ) .unwrap(), - Some(9) + FeeResult::default() + ); + assert_eq!( + drive + .apply_drive_operations( + vec![pool_credit(0)], + true, + &block_info, + None, + platform_version, + None + ) + .unwrap(), + FeeResult::default() ); - drop(transaction); assert_eq!( drive .grove @@ -657,6 +538,144 @@ mod tests { .unwrap(), before ); + assert_eq!(balance(&drive, recipient, platform_version), 0); + assert_eq!(debt(&drive, recipient, platform_version), 7); + } + } + + #[test] + fn should_refuse_processing_pool_credit_accumulation_overflow_before_writing() { + for platform_version in generations() { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let recipient = indebted_identity(&drive, [7; 32], 0, platform_version); + let before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(); + for apply in [false, true] { + let result = drive.apply_drive_operations( + vec![ + DriveOperation::IdentityOperation( + IdentityOperationType::AddToIdentityBalance { + identity_id: recipient, + added_balance: 9, + }, + ), + pool_credit(u64::MAX), + pool_credit(1), + ], + apply, + &debt_test_block_info(), + None, + platform_version, + None, + ); + assert!(matches!(result, Err(Error::Fee(FeeError::Overflow(_))))); + assert_eq!( + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + } + assert_eq!(balance(&drive, recipient, platform_version), 0); + } + } + + #[test] + fn should_roll_back_owned_transaction_when_a_post_batch_processing_pool_return_fails() { + for platform_version in generations() { + for corrupted in [true, false] { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let block_info = debt_test_block_info(); + let recipient = indebted_identity(&drive, [7; 32], 0, platform_version); + let pool = if corrupted { + Element::new_item(vec![1]) + } else { + Element::new_sum_item(i64::MAX) + }; + drive + .grove + .insert( + &block_info.epoch.get_path(), + KEY_POOL_PROCESSING_FEES, + pool, + None, + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .unwrap(); + let before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(); + let batch = vec![ + DriveOperation::IdentityOperation( + IdentityOperationType::AddToIdentityBalance { + identity_id: recipient, + added_balance: 9, + }, + ), + pool_credit(1), + ]; + let result = drive.apply_drive_operations( + batch.clone(), + true, + &block_info, + None, + platform_version, + None, + ); + if corrupted { + assert!(matches!( + result, + Err(Error::Drive(DriveError::UnexpectedElementType(_))) + )); + } else { + assert!(matches!(result, Err(Error::Protocol(_)))); + } + assert_eq!( + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + assert_eq!(balance(&drive, recipient, platform_version), 0); + // A caller-owned transaction retains writes until its caller rolls back/drops it. + let transaction = drive.grove.start_transaction(); + assert!(drive + .apply_drive_operations( + batch, + true, + &block_info, + Some(&transaction), + platform_version, + None + ) + .is_err()); + assert_eq!( + drive + .fetch_identity_balance(recipient, Some(&transaction), platform_version) + .unwrap(), + Some(9) + ); + drop(transaction); + assert_eq!( + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + } } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs index 428bd8799ca..57ee6842da4 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -29,8 +29,8 @@ impl Drive { /// prefunded specialized balances trees) lowered the batch's estimate of that layer. Here /// each operation is lowered with a map of its own, merged into the batch's keeping the /// costlier of two descriptions of one layer (`merge_estimated_layer`: the deeper, then at - /// one depth the one pricing a larger node), so the estimate does not depend on the order - /// of the batch's operations. Applying a batch is generation 1's. + /// one depth the one GroveDB's propagation estimate prices dearer), so the estimate does + /// not depend on the order of the batch's operations. Applying a batch is generation 1's. #[inline(always)] pub(crate) fn apply_drive_operations_v2( &self, @@ -115,8 +115,8 @@ impl Drive { && matches!(drive_op, DriveOperation::DocumentOperation(_)); // An estimate lowers each operation into layer descriptions of its own and merges // them into the batch's, keeping the costlier of two descriptions of one layer (the - // deeper, then at one depth the larger node): the batch's estimate is the same - // whichever order its operations come in. + // deeper, then at one depth the one GroveDB prices dearer): the batch's estimate is + // the same whichever order its operations come in. let mut converted = match estimated_costs_only_with_layer_info.as_mut() { None => drive_op.into_low_level_drive_operations_after_ttl_drain( self, @@ -135,12 +135,7 @@ impl Drive { platform_version, )?; for (path, layer) in operation_layers.unwrap_or_default() { - merge_estimated_layer( - batch_layers, - path, - layer, - &platform_version.drive.grove_version, - )?; + merge_estimated_layer(batch_layers, path, layer, platform_version)?; } converted } diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 353397a0925..d44ef455380 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -6,11 +6,13 @@ //! batch must be priced at the deeper one, whichever write registers its description first. use crate::error::Error; +use crate::fees::op::DriveCost; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::EstimatedLevel; use grovedb::EstimatedLayerInformation; -use grovedb::EstimatedLayerSizes; -use grovedb_version::version::GroveVersion; +use grovedb_merk::estimated_costs::average_case_costs::average_case_merk_propagate; use std::cmp::Ordering; use std::collections::HashMap; @@ -41,71 +43,55 @@ pub(crate) fn insert_estimated_layer_keeping_deeper( estimated_costs_only_with_layer_info.insert(path, layer); } -/// The bytes the estimation prices one node of a layer described as `layer` with: its average key -/// and its value with feature and flags, the larger of each part of a mixed layer. -fn node_bytes( +/// What GroveDB's own estimator charges to carry a change through a layer described as `layer` +/// (`average_case_merk_propagate`: the nodes it rewrites, the bytes it loads and replaces, the +/// hashes), in credits at `platform_version`'s fees. Drive keeps no cost model of its own for a +/// description: GroveDB prices it, and Drive only compares the prices. +fn propagation_credits( layer: &EstimatedLayerInformation, - grove_version: &GroveVersion, -) -> Result { - let key = match &layer.estimated_layer_sizes { - EstimatedLayerSizes::AllSubtrees(key, _, _) - | EstimatedLayerSizes::AllItems(key, _, _) - | EstimatedLayerSizes::AllReference(key, _, _) - | EstimatedLayerSizes::AllItemsWithSumItem(key, _, _) - | EstimatedLayerSizes::AllReferencesWithSumItem(key, _, _) => *key, - EstimatedLayerSizes::Mix { - subtrees_size, - items_size, - references_size, - items_with_sum_item_size, - references_with_sum_item_size, - } => [ - subtrees_size.map(|(key, ..)| key), - items_size.map(|(key, ..)| key), - references_size.map(|(key, ..)| key), - items_with_sum_item_size.map(|(key, ..)| key), - references_with_sum_item_size.map(|(key, ..)| key), - ] - .into_iter() - .flatten() - .max() - .unwrap_or_default(), - }; - let value = layer - .estimated_layer_sizes - .value_with_feature_and_flags_size(grove_version) + platform_version: &PlatformVersion, +) -> Result { + let propagation = average_case_merk_propagate(layer, &platform_version.drive.grove_version); + propagation + .value .map_err(|error| Error::GroveDB(Box::new(error.into())))?; - Ok(u32::from(key) + value) + propagation + .cost + .ephemeral_cost(&platform_version.fee_version) } -/// Orders two descriptions of one layer by what they price an update through it at: the -/// deeper first (`estimate_levels`, which gives a description by element count a depth too), -/// then at one depth the larger node, then, for two that price alike, by their contents, so -/// the order is total and the same whichever is seen first. +/// Orders two descriptions of one layer: the deeper first (`estimate_levels`, which gives a +/// description by element count a depth too), as the depth sets how many nodes every use of the +/// description touches; then at one depth the one GroveDB prices dearer +/// ([`propagation_credits`]); then, for two priced alike, by their contents, so the order is +/// total and the same whichever is seen first. fn compare_layer_cost( a: &EstimatedLayerInformation, b: &EstimatedLayerInformation, - grove_version: &GroveVersion, + platform_version: &PlatformVersion, ) -> Result { Ok(a.estimated_layer_count .estimate_levels() .cmp(&b.estimated_layer_count.estimate_levels()) - .then(node_bytes(a, grove_version)?.cmp(&node_bytes(b, grove_version)?)) + .then( + propagation_credits(a, platform_version)? + .cmp(&propagation_credits(b, platform_version)?), + ) .then_with(|| format!("{a:?}").cmp(&format!("{b:?}")))) } /// Merges `layer` into the batch's description of the layer at `path`, keeping the costlier of /// the two ([`compare_layer_cost`]). The order two operations of a batch register their /// descriptions of a shared layer in no longer matters: the batch's estimate keeps the deeper, -/// and of two at one depth the one pricing a larger node. +/// and of two at one depth the one GroveDB prices dearer. pub(crate) fn merge_estimated_layer( estimated_costs_only_with_layer_info: &mut HashMap, path: KeyInfoPath, layer: EstimatedLayerInformation, - grove_version: &GroveVersion, + platform_version: &PlatformVersion, ) -> Result<(), Error> { if let Some(existing) = estimated_costs_only_with_layer_info.get(&path) { - if compare_layer_cost(existing, &layer, grove_version)? != Ordering::Less { + if compare_layer_cost(existing, &layer, platform_version)? != Ordering::Less { return Ok(()); } } @@ -138,8 +124,8 @@ pub(crate) fn register_keeping_deeper_estimated_layers( mod tests { use super::*; use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; - use grovedb::EstimatedLayerSizes::AllSubtrees; - use grovedb::EstimatedSumTrees::NoSumTrees; + use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; + use grovedb::EstimatedSumTrees::{AllBigSumTrees, NoSumTrees}; use grovedb::TreeType; fn layer(level: u32) -> EstimatedLayerInformation { @@ -179,44 +165,52 @@ mod tests { assert_eq!(level_at(&estimation, &path), Some(1)); } - /// Two descriptions of one layer at one depth that price a node differently merge to the - /// costlier, in either order; a deeper one wins whatever its node; and two that price alike - /// merge to the same one in either order. + /// Two descriptions of one layer merge to the same one in either order: the deeper, then at + /// one depth the one GroveDB's propagation estimate prices dearer, then, for two it prices + /// alike, a fixed choice. #[test] fn should_merge_two_descriptions_of_a_layer_alike_in_either_order() { - let grove_version = GroveVersion::latest(); + let platform_version = PlatformVersion::latest(); let path = KeyInfoPath::from_known_path([[1u8].as_slice()]); - let wide_key = EstimatedLayerInformation { - estimated_layer_sizes: AllSubtrees(17, NoSumTrees, None), - ..layer(3) - }; - let narrow_key = EstimatedLayerInformation { - estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + let at_depth_3 = |estimated_layer_sizes| EstimatedLayerInformation { + estimated_layer_sizes, ..layer(3) }; let merged = |first: EstimatedLayerInformation, second: EstimatedLayerInformation| { let mut estimation = HashMap::new(); - merge_estimated_layer(&mut estimation, path.clone(), first, grove_version) + merge_estimated_layer(&mut estimation, path.clone(), first, platform_version) .expect("expected to merge"); - merge_estimated_layer(&mut estimation, path.clone(), second, grove_version) + merge_estimated_layer(&mut estimation, path.clone(), second, platform_version) .expect("expected to merge"); estimation[&path] }; - assert_eq!(merged(wide_key, narrow_key), wide_key); - assert_eq!(merged(narrow_key, wide_key), wide_key); + let credits = |layer: &EstimatedLayerInformation| { + propagation_credits(layer, platform_version).expect("expected a price") + }; + // Items of 8 bytes under 32-byte keys cost more to carry a change through than big sum + // subtrees under the same keys, though a subtree's element is the larger: GroveDB + // counts the item's two stored hashes. + let items = at_depth_3(AllItems(32, 8, None)); + let subtrees = at_depth_3(AllSubtrees(32, AllBigSumTrees, None)); + assert!(credits(&items) > credits(&subtrees)); + assert_eq!(merged(items, subtrees), items); + assert_eq!(merged(subtrees, items), items); + + // A deeper description wins whatever its nodes. let deeper_narrow = EstimatedLayerInformation { estimated_layer_count: EstimatedLevel(4, false), - ..narrow_key + ..at_depth_3(AllSubtrees(1, NoSumTrees, None)) }; - assert_eq!(merged(wide_key, deeper_narrow), deeper_narrow); - assert_eq!(merged(deeper_narrow, wide_key), deeper_narrow); + assert!(credits(&deeper_narrow) < credits(&items)); + assert_eq!(merged(items, deeper_narrow), deeper_narrow); + assert_eq!(merged(deeper_narrow, items), deeper_narrow); - let flagged = EstimatedLayerInformation { - estimated_layer_sizes: AllSubtrees(1, NoSumTrees, Some(16)), - ..layer(3) - }; - assert_eq!(merged(narrow_key, flagged), merged(flagged, narrow_key)); + // Two different descriptions GroveDB prices alike. + let references = at_depth_3(AllReference(32, 8, None)); + assert_ne!(items, references); + assert_eq!(credits(&items), credits(&references)); + assert_eq!(merged(items, references), merged(references, items)); } #[test] From 3f9225442b65057cb8c461e86586a716dc61af02 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 15:56:55 +0300 Subject: [PATCH 43/62] chore(token-market-contract): version 5.0.0-rc.7 like the rest of the workspace v5.1-dev bumped every package to 5.0.0-rc.7; the token market contract, new on this branch, stayed at rc.4 in its package.json and in Cargo.lock. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- packages/token-market-contract/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 142bfc94e84..19178eb4424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7873,7 +7873,7 @@ dependencies = [ [[package]] name = "token-market-contract" -version = "5.0.0-rc.4" +version = "5.0.0-rc.7" dependencies = [ "base58", "platform-value", diff --git a/packages/token-market-contract/package.json b/packages/token-market-contract/package.json index a44ba6206f2..54fbe0a03b5 100644 --- a/packages/token-market-contract/package.json +++ b/packages/token-market-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/token-market-contract", - "version": "5.0.0-rc.4", + "version": "5.0.0-rc.7", "description": "A system contract for the open orders of the token marketplace", "scripts": { "lint": "eslint .", From 6be9098e5809e417bfbfc8f576e09443d739762f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 17:10:35 +0300 Subject: [PATCH 44/62] test(drive-abci): re-pin shielded fee estimates for protocol 15 batch estimation apply_drive_operations 2 merges each operation's layer descriptions keeping the costlier, which raises three pinned estimates in the shielded pool characterizations by 171,440 credits (the single sponsored payment, shielded and not) and moves the closing mint-group and burn-group settled digests, which cover exact fees. Charged fees and the state root do not change; with protocol 15 on generation 1 the old values return. Co-Authored-By: Claude Opus 5.5 --- .../tests/token/shielded/nullifier_batch_guard.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index dac0d899818..f0abbbc9f98 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -1869,9 +1869,11 @@ async fn should_preserve_a_real_single_payment_sponsor_on_first_check_recheck_an ); let (estimated_storage, estimated_processing, actual_storage, actual_processing) = if shielded { - (171_126_000, 213_217_600, 155_142_000, 92_739_560) + (171_126_000, 213_389_040, 155_142_000, 92_739_560) } else { - (138_024_000, 117_541_680, 126_468_000, 3_870_440) + // The estimate is apply_drive_operations 2's (protocol version 15), which merges + // each operation's layer descriptions keeping the costlier. + (138_024_000, 117_713_120, 126_468_000, 3_870_440) }; assert_eq!( result.execution_results().as_slice(), @@ -2182,7 +2184,7 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "48ab51da4fd56c45d283520a94aa331d237c12ef6f0476f611abc108b5eed0a4", + "92c8c4f60bc675077798bafa6d4d7845b4bacf3a9016042104fcd660e66e2eb8", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", @@ -2190,7 +2192,7 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "9be0c78721144addacf1791b9eda826ded336971b5f06f7e91b82b245c4d5ce8", + "bed612267ff30a8c929e29e1d79d552fc3c4b77a57e91f13214cf7d7c53bec1f", ), _ => unreachable!("enumerated characterization case"), }; From 39c9b051583cee8752bdded2bccf110ed0c5434a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 19:23:25 +0300 Subject: [PATCH 45/62] fix(drive): skip pricing identical layer descriptions; depth proof test checks the root Co-Authored-By: Claude Opus 5.5 --- .../rs-drive/src/drive/tokens/order_escrow/mod.rs | 11 ++++++++++- packages/rs-drive/src/util/estimated_layers.rs | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index f6746397a56..ef8c5f34514 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -1187,9 +1187,18 @@ mod tests { } .distinct_sum_path_query(Some(DEFAULT_QUERY_LIMIT), true, platform_version) .expect("expected the client's path query"); - let (_, elements) = + let (proof_root, elements) = GroveDb::verify_query(&proof, &path_query, &platform_version.drive.grove_version) .expect("expected the proof to verify"); + let live_root = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .value + .expect("expected the current Drive root"); + assert_eq!( + proof_root, live_root, + "the depth proof authenticates the state holding the orders" + ); let sums: Vec = elements .iter() .filter_map(|(_, _, element)| { diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index d44ef455380..828953d6270 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -70,6 +70,10 @@ fn compare_layer_cost( b: &EstimatedLayerInformation, platform_version: &PlatformVersion, ) -> Result { + // The operations of one batch mostly describe a shared layer alike: nothing to price. + if a == b { + return Ok(Ordering::Equal); + } Ok(a.estimated_layer_count .estimate_levels() .cmp(&b.estimated_layer_count.estimate_levels()) From ce1baf5e0b9835a5e7eda87e2d04a51852ae582a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 13:42:50 +0300 Subject: [PATCH 46/62] feat(platform)!: orders keep no $createdAtBlockHeight and no expiresAt Nothing read an order's creation block height: no index, rule or reader used it, and $createdAt already dates the order. expiresAt only stopped fills; it did not close the order or return its escrow, and the book and depth indexes could not tell expired orders apart, so the token market contract drops both. The genesis state at protocol version 15 changes with the contract, so the shielded guard digests are re-pinned. Co-Authored-By: Claude Opus 5.5 --- .../token/shielded/nullifier_batch_guard.rs | 20 ++++++++-------- .../src/drive/tokens/order_escrow/mod.rs | 2 +- packages/token-market-contract/README.md | 1 - .../v1/token-market-contract-documents.json | 24 ++++--------------- packages/token-market-contract/src/lib.rs | 2 -- packages/token-market-contract/src/v1/mod.rs | 2 -- 6 files changed, 16 insertions(+), 35 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index f0abbbc9f98..2eb6e172437 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "fb45513b7e59601be0d41d7c185edb142999d47a82c590d832c2bd9bea4430a6" + "31a2711610cce7bfe3c867f742060b45e87d9ec7040406cb435a7fbc6dbfabb9" ); assert_eq!( pool.platform @@ -2160,39 +2160,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "2567451f467275eb0b0f1627d4d5076a33c1fedff89d4a9ecdf0e58a2c749cc9", + "4f426a32592b74ce2b330c6b74cc078cc96356ccf7c860055d7f66fa7c17c15a", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "a870acb2d5a88b7a24351331450792fe3598ef49b0cbaf6e540dc39f5dc8f66c", + "befd11331ae6dcdda2f227235f693af5be04baebdb1d92fa9c198e171d81de93", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "c913de7a0bd45e7b532551d25ce6269678240ff55ab9a2c07919ff918bad6110", + "9406ec31654e1be46319e776553ff0e1bc5d3777baa5734beac9c44999be2cbe", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "4e86bcf681b218d9412ef6e27cf178282eb0e7c21202a01a77714b9a91f570a8", + "2adb48919121dc0633c71b38ebd950cb2e21e92f4bb78d39a9853dd0c106631d", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "3f99927bcfdd25c2381ca950daee57bc32526334268b804a65f99003d60f46aa", + "30d235d51bf25d5989a42cb7a5e1c041b879290a102194b483dd09a34839a3d9", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "447b44557bc581708a8a7c394f2a6b5c12300b7dcb92a021102bd9c89c3d70d1", + "2b7c116b03552673520ea15835c36762bde578bd247cd930968aeb601c7e4b27", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "92c8c4f60bc675077798bafa6d4d7845b4bacf3a9016042104fcd660e66e2eb8", + "bcb9aa7d6c0114dbf8ca2ad0bc3c69e8207ff12ad984e8b83368a934f7ec59b1", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "2a671dc82aea16abdee786551204b3f668ade6425ed58eb7e1cae2f0e81b79df", + "88c5b2024157cf6e1e4091c5d8c9735a55335afc0f9db246d6baefdc1bced268", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "bed612267ff30a8c929e29e1d79d552fc3c4b77a57e91f13214cf7d7c53bec1f", + "c0ef13aea8123a8d013bee70c6b08a125b4ede6ae53cf46abc3dfba895005c66", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index ef8c5f34514..51825cbcc36 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -1072,7 +1072,7 @@ mod tests { created_at: Some(1_000 + i as u64), updated_at: Some(1_000 + i as u64), transferred_at: None, - created_at_block_height: Some(1), + created_at_block_height: None, updated_at_block_height: None, transferred_at_block_height: None, created_at_core_block_height: None, diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md index db89f6b6e61..89a488db9aa 100644 --- a/packages/token-market-contract/README.md +++ b/packages/token-market-contract/README.md @@ -25,7 +25,6 @@ Tokens a holder offers for sale at a price. | `amount` | integer, required | The base units listed when the order was placed | | `remaining` | integer, required | The base units still for sale; the order is deleted when it is filled entirely | | `minFill` | integer, required | The smallest fill the seller accepts, unless it takes everything still open; a fill may not leave less than this open either | -| `expiresAt` | integer | The block time, in milliseconds, from which the order can no longer be filled; it can still be cancelled | | `decimals` | integer, required | The token's decimals when the order was placed. Every fill prices the order at this scale, so a later change to the token's decimals does not change what an open order costs or pays | ## `bid` diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json index edab7559b3f..84fd73d14d8 100644 --- a/packages/token-market-contract/schema/v1/token-market-contract-documents.json +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -86,18 +86,12 @@ "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", "position": 4 }, - "expiresAt": { - "type": "integer", - "minimum": 0, - "description": "The block time, in milliseconds, from which the order can no longer be filled. It can still be cancelled", - "position": 5 - }, "decimals": { "type": "integer", "minimum": 0, "maximum": 255, "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", - "position": 6 + "position": 5 } }, "required": [ @@ -108,8 +102,7 @@ "minFill", "decimals", "$createdAt", - "$updatedAt", - "$createdAtBlockHeight" + "$updatedAt" ], "additionalProperties": false }, @@ -200,24 +193,18 @@ "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", "position": 4 }, - "expiresAt": { - "type": "integer", - "minimum": 0, - "description": "The block time, in milliseconds, from which the order can no longer be filled. It can still be cancelled", - "position": 5 - }, "escrow": { "type": "integer", "minimum": 0, "description": "The credits still held in escrow for the order. They cover what the open amount costs at the order's price; what is left once it is filled goes back to the buyer", - "position": 6 + "position": 5 }, "decimals": { "type": "integer", "minimum": 0, "maximum": 255, "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", - "position": 7 + "position": 6 } }, "required": [ @@ -229,8 +216,7 @@ "decimals", "escrow", "$createdAt", - "$updatedAt", - "$createdAtBlockHeight" + "$updatedAt" ], "additionalProperties": false } diff --git a/packages/token-market-contract/src/lib.rs b/packages/token-market-contract/src/lib.rs index d1034537a9c..c3c86f89adf 100644 --- a/packages/token-market-contract/src/lib.rs +++ b/packages/token-market-contract/src/lib.rs @@ -108,7 +108,6 @@ mod tests { ask::properties::AMOUNT, ask::properties::REMAINING, ask::properties::MIN_FILL, - ask::properties::EXPIRES_AT, ], ask::properties::REMAINING, ); @@ -120,7 +119,6 @@ mod tests { bid::properties::AMOUNT, bid::properties::REMAINING, bid::properties::MIN_FILL, - bid::properties::EXPIRES_AT, bid::properties::ESCROW, ], bid::properties::ESCROW, diff --git a/packages/token-market-contract/src/v1/mod.rs b/packages/token-market-contract/src/v1/mod.rs index 93f77355dc4..ead36928143 100644 --- a/packages/token-market-contract/src/v1/mod.rs +++ b/packages/token-market-contract/src/v1/mod.rs @@ -12,7 +12,6 @@ pub mod document_types { pub const AMOUNT: &str = "amount"; pub const REMAINING: &str = "remaining"; pub const MIN_FILL: &str = "minFill"; - pub const EXPIRES_AT: &str = "expiresAt"; pub const DECIMALS: &str = "decimals"; } @@ -34,7 +33,6 @@ pub mod document_types { pub const AMOUNT: &str = "amount"; pub const REMAINING: &str = "remaining"; pub const MIN_FILL: &str = "minFill"; - pub const EXPIRES_AT: &str = "expiresAt"; pub const ESCROW: &str = "escrow"; pub const DECIMALS: &str = "decimals"; } From ad0d84f80a741b979735e1d7ce09cf2efc2d75fb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 13:42:50 +0300 Subject: [PATCH 47/62] fix(drive): price layer descriptions only when their depths tie; the escrow upgrade guard compares authenticated hashes compare_layer_cost ranked by depth but priced both descriptions first; it now prices them only when the depths tie, so an irrelevant pricing error cannot fail a merge. The genesis-versus-upgrade escrow test also compares the value hash of each root element, which commits to the Merk root of the subtree below it, from a raw proof of the committed state. Co-Authored-By: Claude Opus 5.5 --- .../v0/mod.rs | 39 +++++++++++++++++++ .../rs-drive/src/util/estimated_layers.rs | 21 ++++++---- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index f9b5d77a64f..3b1032976b2 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -4030,6 +4030,45 @@ mod tests { genesis-v15 node and an in-place-upgraded one" ); } + + // The walks above compare decoded keys and elements, which carry no Merk hash: two + // subtrees holding the same entries in a differently balanced tree would pass them. + // A tree element's value hash in its parent Merk commits to the root hash of the Merk + // below it, and so to everything that subtree holds and how it is arranged; a raw proof + // of the root element yields it. GroveDB proves only committed state, so the upgrade is + // committed first. + platform_b + .drive + .grove + .commit_transaction(txn_b) + .unwrap() + .expect("expected to commit the upgrade"); + let subtree_hash = |platform: &crate::platform_types::platform::Platform< + crate::rpc::core::MockCoreRPCLike, + >, + root: u8| { + let grove_version = &platform_version_15.drive.grove_version; + let query = drive::grovedb::PathQuery::new_single_key(vec![], vec![root]); + let proof = platform + .drive + .grove + .get_proved_path_query(&query, None, None, grove_version) + .unwrap() + .expect("expected to prove the root element"); + let (_, proved) = + drive::grovedb::GroveDb::verify_query_raw(&proof, &query, grove_version) + .expect("expected the proof of the root element to verify"); + assert_eq!(proved.len(), 1, "expected the root element alone"); + proved[0].proof + }; + for root in roots { + assert_eq!( + subtree_hash(&platform_a, root), + subtree_hash(&platform_b, root), + "CONSENSUS FORK: root {root} authenticates to a different hash on a fresh \ + genesis-v15 node and an in-place-upgraded one" + ); + } } #[test] diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 828953d6270..5d8f433e085 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -74,14 +74,21 @@ fn compare_layer_cost( if a == b { return Ok(Ordering::Equal); } - Ok(a.estimated_layer_count + let by_depth = a + .estimated_layer_count .estimate_levels() - .cmp(&b.estimated_layer_count.estimate_levels()) - .then( - propagation_credits(a, platform_version)? - .cmp(&propagation_credits(b, platform_version)?), - ) - .then_with(|| format!("{a:?}").cmp(&format!("{b:?}")))) + .cmp(&b.estimated_layer_count.estimate_levels()); + if by_depth != Ordering::Equal { + return Ok(by_depth); + } + // Priced only when the depths tie, so a pricing error of a description the depth already + // ranks cannot fail the comparison. + let by_price = + propagation_credits(a, platform_version)?.cmp(&propagation_credits(b, platform_version)?); + if by_price != Ordering::Equal { + return Ok(by_price); + } + Ok(format!("{a:?}").cmp(&format!("{b:?}"))) } /// Merges `layer` into the batch's description of the layer at `path`, keeping the costlier of From b97c64614e037d62a218d9c251700b444d1fa1cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 17:27:52 +0300 Subject: [PATCH 48/62] chore: token-market-contract follows the 5.0.0-rc.8 release bump Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- packages/token-market-contract/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f8d6ebae4e..a03d30b500b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7876,7 +7876,7 @@ dependencies = [ [[package]] name = "token-market-contract" -version = "5.0.0-rc.7" +version = "5.0.0-rc.8" dependencies = [ "base58", "platform-value", diff --git a/packages/token-market-contract/package.json b/packages/token-market-contract/package.json index 54fbe0a03b5..96917d0ca74 100644 --- a/packages/token-market-contract/package.json +++ b/packages/token-market-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/token-market-contract", - "version": "5.0.0-rc.7", + "version": "5.0.0-rc.8", "description": "A system contract for the open orders of the token marketplace", "scripts": { "lint": "eslint .", From 436c30cab7b9edd7562e1ce4619ec9720b8609cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 18:00:49 +0300 Subject: [PATCH 49/62] docs(platform-version): the token market contract's table entry cites note 8 Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 4494a8ac0f2..87c216c9d21 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -209,7 +209,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3); the token market contract is selected (note 6) + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3); the token market contract is selected (note 8) fee_version: FEE_VERSION3, system_limits: SYSTEM_LIMITS_V4, consensus: ConsensusVersions { From 2eb9666c1b6b8ae024f18c76f84f1aa8c1a87120 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 01:11:48 +0300 Subject: [PATCH 50/62] fix(drive): layer descriptions that tie on depth and price are ordered structurally The last tie-break compared Debug-formatted strings, which a dependency's formatting could change. layer_key orders by every field in turn, each enum by its variant's position, matching every variant explicitly. Co-Authored-By: Claude Opus 5.5 --- .../rs-drive/src/util/estimated_layers.rs | 138 +++++++++++++++++- 1 file changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 5d8f433e085..12bdf1e0b65 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -11,7 +11,10 @@ use dpp::fee::Credits; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::EstimatedLevel; -use grovedb::EstimatedLayerInformation; +use grovedb::{ + EstimatedLayerCount, EstimatedLayerInformation, EstimatedLayerSizes, EstimatedSumTrees, + TreeType, +}; use grovedb_merk::estimated_costs::average_case_costs::average_case_merk_propagate; use std::cmp::Ordering; use std::collections::HashMap; @@ -63,8 +66,8 @@ fn propagation_credits( /// Orders two descriptions of one layer: the deeper first (`estimate_levels`, which gives a /// description by element count a depth too), as the depth sets how many nodes every use of the /// description touches; then at one depth the one GroveDB prices dearer -/// ([`propagation_credits`]); then, for two priced alike, by their contents, so the order is -/// total and the same whichever is seen first. +/// ([`propagation_credits`]); then, for two priced alike, by their contents ([`layer_key`]), so +/// the order is total and the same whichever is seen first. fn compare_layer_cost( a: &EstimatedLayerInformation, b: &EstimatedLayerInformation, @@ -88,7 +91,134 @@ fn compare_layer_cost( if by_price != Ordering::Equal { return Ok(by_price); } - Ok(format!("{a:?}").cmp(&format!("{b:?}"))) + Ok(layer_key(a).cmp(&layer_key(b))) +} + +/// A total order on descriptions, for two GroveDB prices alike: every field in turn, each enum +/// by the position of its variant. The matches name every variant, so a description GroveDB adds +/// cannot fall into an ordering by accident; and unlike a formatted string, the key does not +/// change with a dependency's `Debug` output. +fn layer_key(layer: &EstimatedLayerInformation) -> Vec { + let mut key = vec![]; + tree_type_key(layer.tree_type, &mut key); + match layer.estimated_layer_count { + EstimatedLayerCount::PotentiallyAtMaxElements => key.push(0), + EstimatedLayerCount::ApproximateElements(count) => key.extend([1, count as u64]), + EstimatedLayerCount::EstimatedLevel(level, to_be_empty) => { + key.extend([2, level as u64, to_be_empty as u64]) + } + } + let flags = |flags: Option| flags.map_or(0, |flags| flags as u64 + 1); + let sized = |key: &mut Vec, entry: Option<(u8, u32, Option, u8)>| match entry { + None => key.push(0), + Some((key_size, value_size, flags_size, weight)) => key.extend([ + 1, + key_size as u64, + value_size as u64, + flags(flags_size), + weight as u64, + ]), + }; + match layer.estimated_layer_sizes { + EstimatedLayerSizes::AllSubtrees(key_size, sum_trees, flags_size) => { + key.extend([0, key_size as u64]); + sum_trees_key(sum_trees, &mut key); + key.push(flags(flags_size)); + } + EstimatedLayerSizes::AllItems(key_size, value_size, flags_size) => { + key.extend([1, key_size as u64, value_size as u64, flags(flags_size)]) + } + EstimatedLayerSizes::AllReference(key_size, value_size, flags_size) => { + key.extend([2, key_size as u64, value_size as u64, flags(flags_size)]) + } + EstimatedLayerSizes::AllItemsWithSumItem(key_size, value_size, flags_size) => { + key.extend([3, key_size as u64, value_size as u64, flags(flags_size)]) + } + EstimatedLayerSizes::AllReferencesWithSumItem(key_size, value_size, flags_size) => { + key.extend([4, key_size as u64, value_size as u64, flags(flags_size)]) + } + EstimatedLayerSizes::Mix { + subtrees_size, + items_size, + references_size, + items_with_sum_item_size, + references_with_sum_item_size, + } => { + key.push(5); + match subtrees_size { + None => key.push(0), + Some((key_size, sum_trees, flags_size, weight)) => { + key.extend([1, key_size as u64]); + sum_trees_key(sum_trees, &mut key); + key.extend([flags(flags_size), weight as u64]); + } + } + sized(&mut key, items_size); + sized(&mut key, references_size); + sized(&mut key, items_with_sum_item_size); + sized(&mut key, references_with_sum_item_size); + } + } + key +} + +fn tree_type_key(tree_type: TreeType, key: &mut Vec) { + let (variant, parameter) = match tree_type { + TreeType::NormalTree => (0, 0), + TreeType::SumTree => (1, 0), + TreeType::BigSumTree => (2, 0), + TreeType::CountTree => (3, 0), + TreeType::CountSumTree => (4, 0), + TreeType::ProvableCountTree => (5, 0), + TreeType::ProvableCountSumTree => (6, 0), + TreeType::CommitmentTree(chunk_power) => (7, chunk_power), + TreeType::MmrTree => (8, 0), + TreeType::BulkAppendTree(chunk_power) => (9, chunk_power), + TreeType::DenseAppendOnlyFixedSizeTree(height) => (10, height), + TreeType::ProvableSumTree => (11, 0), + TreeType::ProvableCountProvableSumTree => (12, 0), + TreeType::ProvableSumIndexedTree => (13, 0), + TreeType::ProvableCountIndexedTree => (14, 0), + TreeType::ProvableCountProvableSumIndexedTree => (15, 0), + TreeType::PrivateDocumentStore(parameter) => (16, parameter), + }; + key.extend([variant, parameter as u64]); +} + +fn sum_trees_key(sum_trees: EstimatedSumTrees, key: &mut Vec) { + match sum_trees { + EstimatedSumTrees::NoSumTrees => key.push(0), + EstimatedSumTrees::SomeSumTrees { + sum_trees_weight, + big_sum_trees_weight, + count_trees_weight, + count_sum_trees_weight, + non_sum_trees_weight, + provable_sum_trees_weight, + provable_count_trees_weight, + provable_count_sum_trees_weight, + provable_count_provable_sum_trees_weight, + } => key.extend([ + 1, + sum_trees_weight as u64, + big_sum_trees_weight as u64, + count_trees_weight as u64, + count_sum_trees_weight as u64, + non_sum_trees_weight as u64, + provable_sum_trees_weight as u64, + provable_count_trees_weight as u64, + provable_count_sum_trees_weight as u64, + provable_count_provable_sum_trees_weight as u64, + ]), + EstimatedSumTrees::AllSumTrees => key.push(2), + EstimatedSumTrees::AllBigSumTrees => key.push(3), + EstimatedSumTrees::AllCountTrees => key.push(4), + EstimatedSumTrees::AllCountSumTrees => key.push(5), + EstimatedSumTrees::AllProvableSumTrees => key.push(6), + EstimatedSumTrees::AllProvableCountTrees => key.push(7), + EstimatedSumTrees::AllProvableCountSumTrees => key.push(8), + EstimatedSumTrees::AllProvableCountProvableSumTrees => key.push(9), + } } /// Merges `layer` into the batch's description of the layer at `path`, keeping the costlier of From a5f949896d35a3d567c5b1aca5bc43a813c8dca6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 08:57:56 +0300 Subject: [PATCH 51/62] docs(platform-version): the DPNS note no longer says the data trigger bindings stay at 2 Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 87c216c9d21..70ee0fa6b31 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -112,9 +112,10 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `DRIVE_ABCI_VALIDATION_VERSIONS_V11` selects `create_domain_data_trigger` /// 2, which keeps only the parent domain checks: a top-level domain only by /// the contract owner, the parent present, no subdomains under a name, and -/// the parent's subdomain rule. The data trigger bindings stay at 2: their -/// `domain` Replace and Delete rejects are never reached, as +/// the parent's subdomain rule. DPNS needs no new data trigger bindings: +/// its `domain` Replace and Delete rejects are never reached, as /// `documentsMutable` and `canBeDeleted` false refuse both first (10404). +/// (The bindings move to 3 for the token market contract alone, note 8.) /// /// 4. **`countOf` and `sumOf` match byte array keys**: `CONTRACT_VERSIONS_V7` /// selects `parse_property_constraints` 1, whose grammar binds a filter key From affc1e2bde3e8f3bc529e5b739c5264f63c917af Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 12:24:22 +0300 Subject: [PATCH 52/62] fix(drive): keeping the deeper layer description compares every kind of depth insert_estimated_layer_keeping_deeper compared only explicit levels, so a description of a layer that may hold the most elements (32 levels, GroveDB's worst case) gave way to any shallower level. It now compares estimate_levels() for every description, keeping the deeper. Co-Authored-By: Claude Opus 5.5 --- .../rs-drive/src/util/estimated_layers.rs | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 12bdf1e0b65..bb9679f73b8 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -19,28 +19,19 @@ use grovedb_merk::estimated_costs::average_case_costs::average_case_merk_propaga use std::cmp::Ordering; use std::collections::HashMap; -/// The level a description gives its layer, `None` for one described by its element count. -fn estimated_level(layer: &EstimatedLayerInformation) -> Option { - match layer.estimated_layer_count { - EstimatedLevel(level, _) => Some(level), - _ => None, - } -} - -/// Describes the layer at `path` as `layer`, unless the batch already describes it at a level at -/// least as deep. +/// Describes the layer at `path` as `layer`, unless the batch already describes it at least as +/// deep (`estimate_levels`, which gives a description by element count a depth too: one that may +/// hold the most elements a layer can is 32 levels deep, the worst case). pub(crate) fn insert_estimated_layer_keeping_deeper( estimated_costs_only_with_layer_info: &mut HashMap, path: KeyInfoPath, layer: EstimatedLayerInformation, ) { if let Some(existing) = estimated_costs_only_with_layer_info.get(&path) { - if let (Some(existing_level), Some(level)) = - (estimated_level(existing), estimated_level(&layer)) + if existing.estimated_layer_count.estimate_levels() + >= layer.estimated_layer_count.estimate_levels() { - if existing_level >= level { - return; - } + return; } } estimated_costs_only_with_layer_info.insert(path, layer); @@ -264,7 +255,7 @@ pub(crate) fn register_keeping_deeper_estimated_layers( #[cfg(test)] mod tests { use super::*; - use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; + use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; use grovedb::EstimatedSumTrees::{AllBigSumTrees, NoSumTrees}; use grovedb::TreeType; @@ -281,7 +272,12 @@ mod tests { estimation: &HashMap, path: &KeyInfoPath, ) -> Option { - estimation.get(path).and_then(estimated_level) + estimation + .get(path) + .and_then(|layer| match layer.estimated_layer_count { + EstimatedLevel(level, _) => Some(level), + _ => None, + }) } #[test] @@ -294,16 +290,25 @@ mod tests { assert_eq!(level_at(&estimation, &path), Some(2)); insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(1)); assert_eq!(level_at(&estimation, &path), Some(2)); - // A description by element count is replaced as before. + // A description by element count has a depth too: one that may hold the most elements + // a layer can (32 levels, the worst case) is kept over a shallower level, and one of a + // few elements gives way to a deeper level. + let at_max = EstimatedLayerInformation { + estimated_layer_count: PotentiallyAtMaxElements, + ..layer(0) + }; + estimation.insert(path.clone(), at_max); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(5)); + assert_eq!(estimation.get(&path), Some(&at_max)); estimation.insert( path.clone(), EstimatedLayerInformation { - estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_count: ApproximateElements(3), ..layer(0) }, ); - insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(1)); - assert_eq!(level_at(&estimation, &path), Some(1)); + insert_estimated_layer_keeping_deeper(&mut estimation, path.clone(), layer(5)); + assert_eq!(level_at(&estimation, &path), Some(5)); } /// Two descriptions of one layer merge to the same one in either order: the deeper, then at From 96857322af2bb4c24e6ca293d78348f3ea9b2a55 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 12:33:02 +0300 Subject: [PATCH 53/62] fix(drive): EstimatedLevel is imported by the tests that use it Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/src/util/estimated_layers.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index bb9679f73b8..1b7e1b791f0 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -10,7 +10,6 @@ use crate::fees::op::DriveCost; use dpp::fee::Credits; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerCount::EstimatedLevel; use grovedb::{ EstimatedLayerCount, EstimatedLayerInformation, EstimatedLayerSizes, EstimatedSumTrees, TreeType, @@ -255,7 +254,9 @@ pub(crate) fn register_keeping_deeper_estimated_layers( #[cfg(test)] mod tests { use super::*; - use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; + use grovedb::EstimatedLayerCount::{ + ApproximateElements, EstimatedLevel, PotentiallyAtMaxElements, + }; use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; use grovedb::EstimatedSumTrees::{AllBigSumTrees, NoSumTrees}; use grovedb::TreeType; From 459a5841412b5f966187a2102e04557d39fe9ea4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 18:43:57 +0300 Subject: [PATCH 54/62] fix(sdk): the trusted provider serves no token market contract before protocol version 15 The known-contract cache was read before the system contracts' activation check, so a token market contract preloaded with add_known_contract was served at protocol version 14. The lookup now answers None for its id below activation first. Co-Authored-By: Claude Opus 5.5 --- .../src/provider.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 1ce49a2d96a..db43f97ef50 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -1035,6 +1035,15 @@ impl ContextProvider for TrustedHttpContextProvider { id: &Identifier, platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { + // The token market contract exists only from its activation version: below it no + // contract is served under its id, including one preloaded into the known contracts. + #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::TokenMarket.id() + && platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION + { + return Ok(None); + } + // First check known contracts cache let known = self.known_contracts.lock().unwrap(); if let Some(contract) = known.get(id) { @@ -2566,6 +2575,17 @@ mod tests { .expect("the lookup must succeed at protocol version 15") .expect("the token market contract must be served at protocol version 15"); assert_eq!(contract.id(), id); + + // A copy preloaded into the known contracts is not served before activation either. + provider.add_known_contract((*contract).clone()); + assert!(provider + .get_data_contract(&id, PlatformVersion::get(14).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + assert!(provider + .get_data_contract(&id, PlatformVersion::get(15).unwrap()) + .expect("the lookup must succeed at protocol version 15") + .is_some()); } #[test] From d00de30d492916b28600f5952f87838a6c608b11 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 21:09:30 +0300 Subject: [PATCH 55/62] docs(platform-version): name every slot PLATFORM_V15 changes for the token marketplace The DRIVE_VERSION_V10 comment now lists create_initial_state_structure 5, calculate_total_tokens_balance 2, the escrow primitives, the four estimation generations that keep a deeper layer description and apply_drive_operations 2; the DRIVE_ABCI_VALIDATION_VERSIONS_V11 comment adds data trigger bindings 3. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 70ee0fa6b31..2eb8062205d 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -185,11 +185,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: the token marketplace escrow trees and calculate_total_tokens_balance 2 + drive: DRIVE_VERSION_V10, // changed: create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description; apply_drive_operations 2 merges each operation's layer descriptions (note 8) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3) + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); data trigger bindings 3 refuse a replace or a delete of a token market order (note 8) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, From 74e517b0c7a7e9430620a3ee2cfd70e619f33cac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 23:13:15 +0300 Subject: [PATCH 56/62] fix(drive): merge a shared layer's descriptions into one covering both (PV15) apply_drive_operations 2 kept the deeper of two descriptions of one layer, even when GroveDB priced the shallower one's nodes dearer, and GroveDB gives every operation at that path the one kept description. The merge now keeps the deeper depth and, at that depth, the nodes GroveDB prices dearer (the shallower description priced at the deeper depth), so no operation is estimated below its own description. No pinned fee moves. The notes and the two estimators that deferred to the batch's root description now say that from protocol version 15 the merge raises the estimate of any batch whose operations describe a shared layer differently, an ordinary document transition included. The trusted context provider's token market activation gate no longer depends on the contract's feature: a contract preloaded under the token market's id is refused before protocol version 15 in every build. Co-Authored-By: Claude Opus 5.5 --- .../token/shielded/nullifier_batch_guard.rs | 4 +- .../fee_pots/estimated_costs/v0/mod.rs | 5 +- .../rs-drive/src/drive/contract/other_tree.rs | 5 +- .../apply_drive_operations/v2/mod.rs | 21 ++-- .../rs-drive/src/util/estimated_layers.rs | 96 ++++++++++++------- .../src/version/drive_versions/v10.rs | 8 +- .../rs-platform-version/src/version/v15.rs | 6 +- .../src/provider.rs | 71 ++++++++++++-- 8 files changed, 158 insertions(+), 58 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 2eb6e172437..52dcf099acd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -1867,12 +1867,12 @@ async fn should_preserve_a_real_single_payment_sponsor_on_first_check_recheck_an "SINGLE SPONSORED PAYMENT shielded={shielded}: {:?}", result.execution_results() ); + // Both estimates are apply_drive_operations 2's (protocol version 15), which merges each + // operation's layer descriptions into one covering both. let (estimated_storage, estimated_processing, actual_storage, actual_processing) = if shielded { (171_126_000, 213_389_040, 155_142_000, 92_739_560) } else { - // The estimate is apply_drive_operations 2's (protocol version 15), which merges - // each operation's layer descriptions keeping the costlier. (138_024_000, 117_713_120, 126_468_000, 3_870_440) }; assert_eq!( diff --git a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs index e04968f913e..45e1253d383 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs @@ -19,7 +19,10 @@ impl Drive { estimated_costs_only_with_layer_info: &mut HashMap, ) { // The root layer is left alone when the batch already describes it: a document - // operation of the same batch describes it as well as this does. + // operation of the same batch describes it as well as this does. That holds while the + // batch shares one map (`apply_drive_operations` 1); from protocol version 15 + // (`apply_drive_operations` 2) each operation is estimated with a map of its own, so this + // always describes the root and the batch merges it with the document's. estimated_costs_only_with_layer_info .entry(KeyInfoPath::from_known_path([])) .or_insert(EstimatedLayerInformation { diff --git a/packages/rs-drive/src/drive/contract/other_tree.rs b/packages/rs-drive/src/drive/contract/other_tree.rs index a2561d5963e..aa4f1116308 100644 --- a/packages/rs-drive/src/drive/contract/other_tree.rs +++ b/packages/rs-drive/src/drive/contract/other_tree.rs @@ -19,7 +19,10 @@ impl Drive { /// and up to three moderation list trees), and of the contract's root subtree above it when /// nothing in the batch described that one yet. The root subtree's entry is left alone /// when it is there: a document or contract operation of the same batch describes it - /// better than this does. + /// better than this does. That holds while the batch shares one map + /// (`apply_drive_operations` 1); from protocol version 15 (`apply_drive_operations` 2) each + /// operation is estimated with a map of its own, so the batch merges this description with + /// the other operation's. pub(crate) fn add_estimation_costs_for_contract_other_tree( contract_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs index 57ee6842da4..1fbb805866b 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -27,10 +27,15 @@ impl Drive { /// describe a layer sets its level: a document, a nonce or a balance write built after a /// write deeper in the same layer (the token marketplace's escrows, in the Tokens and the /// prefunded specialized balances trees) lowered the batch's estimate of that layer. Here - /// each operation is lowered with a map of its own, merged into the batch's keeping the - /// costlier of two descriptions of one layer (`merge_estimated_layer`: the deeper, then at - /// one depth the one GroveDB's propagation estimate prices dearer), so the estimate does - /// not depend on the order of the batch's operations. Applying a batch is generation 1's. + /// each operation is lowered with a map of its own, merged into the batch's as one + /// description that covers both of two descriptions of one layer (`merge_estimated_layer`: + /// the deeper depth, and at that depth the nodes GroveDB's propagation estimate prices + /// dearer), so the estimate does not depend on the order of the batch's operations and no + /// operation is priced below its own description. That raises the estimate of any batch + /// whose operations describe a shared layer differently, not only the marketplace's: a + /// document's nonce update describes the root a level deeper than the document does, so + /// the root of every document batch is now estimated at the nonce's depth. Applying a batch + /// is generation 1's. #[inline(always)] pub(crate) fn apply_drive_operations_v2( &self, @@ -114,8 +119,8 @@ impl Drive { let forfeited = separates_forfeited_operations && matches!(drive_op, DriveOperation::DocumentOperation(_)); // An estimate lowers each operation into layer descriptions of its own and merges - // them into the batch's, keeping the costlier of two descriptions of one layer (the - // deeper, then at one depth the one GroveDB prices dearer): the batch's estimate is + // them into the batch's, one description covering both of two of one layer (the + // deeper depth, with the nodes GroveDB prices dearer there): the batch's estimate is // the same whichever order its operations come in. let mut converted = match estimated_costs_only_with_layer_info.as_mut() { None => drive_op.into_low_level_drive_operations_after_ttl_drain( @@ -280,8 +285,8 @@ mod tests { } /// A token mint and a prefunded balance write both describe the root at depth 3, with - /// different key sizes and sum trees: the batch's estimate keeps the costlier of the two, - /// whichever comes first. + /// different key sizes and sum trees: the batch's estimate keeps the nodes GroveDB prices + /// dearer, whichever comes first. #[test] fn should_estimate_two_descriptions_of_one_depth_alike_in_either_order() { let latest = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 1b7e1b791f0..a6c4fa00ac8 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -53,35 +53,48 @@ fn propagation_credits( .ephemeral_cost(&platform_version.fee_version) } -/// Orders two descriptions of one layer: the deeper first (`estimate_levels`, which gives a -/// description by element count a depth too), as the depth sets how many nodes every use of the -/// description touches; then at one depth the one GroveDB prices dearer -/// ([`propagation_credits`]); then, for two priced alike, by their contents ([`layer_key`]), so -/// the order is total and the same whichever is seen first. -fn compare_layer_cost( +/// The description a batch keeps for a layer two of its operations describe as `a` and `b`. GroveDB +/// gives every operation at the layer's path this one description, so it must cover each of +/// them: it is as deep as the deeper (`estimate_levels`, which gives a description by element +/// count a depth too), as the depth sets how many nodes every use of the description touches, +/// and at that depth it has the nodes of whichever GroveDB prices dearer +/// ([`propagation_credits`]), the shallower one being priced at the deeper one's depth. Two +/// priced alike are told apart by their contents ([`layer_key`]), so the result is the same +/// whichever is seen first. +fn costlier_layer( a: &EstimatedLayerInformation, b: &EstimatedLayerInformation, platform_version: &PlatformVersion, -) -> Result { +) -> Result { // The operations of one batch mostly describe a shared layer alike: nothing to price. if a == b { - return Ok(Ordering::Equal); + return Ok(*a); } - let by_depth = a + let at_depth_of = |layer: &EstimatedLayerInformation, deeper: &EstimatedLayerInformation| { + EstimatedLayerInformation { + estimated_layer_count: deeper.estimated_layer_count, + ..*layer + } + }; + let (a, b) = match a .estimated_layer_count .estimate_levels() - .cmp(&b.estimated_layer_count.estimate_levels()); - if by_depth != Ordering::Equal { - return Ok(by_depth); + .cmp(&b.estimated_layer_count.estimate_levels()) + { + Ordering::Greater => (*a, at_depth_of(b, a)), + Ordering::Less => (at_depth_of(a, b), *b), + Ordering::Equal => (*a, *b), + }; + if a == b { + return Ok(a); } - // Priced only when the depths tie, so a pricing error of a description the depth already - // ranks cannot fail the comparison. let by_price = - propagation_credits(a, platform_version)?.cmp(&propagation_credits(b, platform_version)?); - if by_price != Ordering::Equal { - return Ok(by_price); - } - Ok(layer_key(a).cmp(&layer_key(b))) + propagation_credits(&a, platform_version)?.cmp(&propagation_credits(&b, platform_version)?); + let order = match by_price { + Ordering::Equal => layer_key(&a).cmp(&layer_key(&b)), + by_price => by_price, + }; + Ok(if order == Ordering::Less { b } else { a }) } /// A total order on descriptions, for two GroveDB prices alike: every field in turn, each enum @@ -211,22 +224,21 @@ fn sum_trees_key(sum_trees: EstimatedSumTrees, key: &mut Vec) { } } -/// Merges `layer` into the batch's description of the layer at `path`, keeping the costlier of -/// the two ([`compare_layer_cost`]). The order two operations of a batch register their -/// descriptions of a shared layer in no longer matters: the batch's estimate keeps the deeper, -/// and of two at one depth the one GroveDB prices dearer. +/// Merges `layer` into the batch's description of the layer at `path` ([`costlier_layer`]): the +/// depth of the deeper of the two and, at that depth, the nodes GroveDB prices dearer. The order +/// two operations of a batch register their descriptions of a shared layer in no longer matters, +/// and the description priced for each of them is never cheaper than its own. pub(crate) fn merge_estimated_layer( estimated_costs_only_with_layer_info: &mut HashMap, path: KeyInfoPath, layer: EstimatedLayerInformation, platform_version: &PlatformVersion, ) -> Result<(), Error> { - if let Some(existing) = estimated_costs_only_with_layer_info.get(&path) { - if compare_layer_cost(existing, &layer, platform_version)? != Ordering::Less { - return Ok(()); - } - } - estimated_costs_only_with_layer_info.insert(path, layer); + let merged = match estimated_costs_only_with_layer_info.get(&path) { + Some(existing) => costlier_layer(existing, &layer, platform_version)?, + None => layer, + }; + estimated_costs_only_with_layer_info.insert(path, merged); Ok(()) } @@ -312,8 +324,8 @@ mod tests { assert_eq!(level_at(&estimation, &path), Some(5)); } - /// Two descriptions of one layer merge to the same one in either order: the deeper, then at - /// one depth the one GroveDB's propagation estimate prices dearer, then, for two it prices + /// Two descriptions of one layer merge to the same one in either order: the deeper depth, the + /// nodes GroveDB's propagation estimate prices dearer at that depth, then, for two it prices /// alike, a fixed choice. #[test] fn should_merge_two_descriptions_of_a_layer_alike_in_either_order() { @@ -344,14 +356,30 @@ mod tests { assert_eq!(merged(items, subtrees), items); assert_eq!(merged(subtrees, items), items); - // A deeper description wins whatever its nodes. + // A deeper description of narrower nodes, priced below the shallower one: the merge + // keeps the deeper depth and the dearer nodes, so neither operation is priced below its + // own description. let deeper_narrow = EstimatedLayerInformation { estimated_layer_count: EstimatedLevel(4, false), ..at_depth_3(AllSubtrees(1, NoSumTrees, None)) }; assert!(credits(&deeper_narrow) < credits(&items)); - assert_eq!(merged(items, deeper_narrow), deeper_narrow); - assert_eq!(merged(deeper_narrow, items), deeper_narrow); + let deeper_items = EstimatedLayerInformation { + estimated_layer_count: EstimatedLevel(4, false), + ..items + }; + assert_eq!(merged(items, deeper_narrow), deeper_items); + assert_eq!(merged(deeper_narrow, items), deeper_items); + assert!(credits(&deeper_items) >= credits(&items)); + assert!(credits(&deeper_items) >= credits(&deeper_narrow)); + + // The deeper one's nodes are kept when they are the dearer at its depth. + let deeper_wide = EstimatedLayerInformation { + estimated_layer_count: EstimatedLevel(4, false), + ..at_depth_3(AllItems(32, 64, None)) + }; + assert_eq!(merged(items, deeper_wide), deeper_wide); + assert_eq!(merged(deeper_wide, items), deeper_wide); // Two different descriptions GroveDB prices alike. let references = at_depth_3(AllReference(32, 8, None)); diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 8c2456db492..fab7971702a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -34,8 +34,10 @@ use crate::version::drive_versions::{ /// `for_balances` 1, `for_token_balances` 1 and the voting balance estimation 2 keep a deeper /// description of the root as well. Beyond the estimators, `apply_drive_operations` 2 lowers /// each operation of an estimated batch with layer descriptions of its own and merges them -/// keeping the deeper one, so no other write of the batch (a document, a nonce) lowers what -/// the escrows describe, whichever is built first. +/// into one covering both (the deeper depth, with the nodes priced dearer there), so no other +/// write of the batch (a document, a nonce) lowers what the escrows describe, whichever is +/// built first. It raises the estimate of any batch whose operations describe a shared layer +/// differently, an ordinary document transition included. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -60,7 +62,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, batch_operations: DriveBatchOperationsMethodVersion { - apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions keeping the deeper + apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both ..DRIVE_VERSION_V9.methods.batch_operations }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 2eb8062205d..925a349b8b4 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -174,7 +174,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// the voting balance and contract fee pot estimations describe, so their v2 and v1 keep a /// deeper description of that layer another write of the batch registered; and /// `apply_drive_operations` 2 merges each operation's estimated layer descriptions into the -/// batch's keeping the deeper one, so an estimate does not depend on the operations' order. +/// batch's as one covering both (the deeper depth, with the nodes priced dearer there), so an +/// estimate does not depend on the operations' order. That raises the estimated fee of any +/// batch whose operations describe a shared layer differently, an ordinary document +/// transition included (its nonce update describes the root a level deeper than the +/// document); the fee a transition pays is unchanged. /// `SYSTEM_DATA_CONTRACT_VERSIONS_V4` selects the token market contract v1, whose /// `ask` and `bid` types hold the open orders, each with the token's decimals when it was /// placed, which prices its fills and keys its `book` and `depth` index entries diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index db43f97ef50..a2780f0c717 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -35,7 +35,6 @@ use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIA feature = "all-system-contracts" ))] use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; -#[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] use dpp::version::feature_initial_protocol_versions::TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; @@ -63,6 +62,15 @@ use url::Url; #[cfg(target_arch = "wasm32")] const WASM_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +/// The token market system contract's id (`SystemDataContract::TokenMarket`). It is spelled out +/// here so that every build, with or without the contract's feature, refuses to serve a contract +/// under this id before the contract exists (protocol version 15), a preloaded known contract +/// included. A test checks it against `SystemDataContract::TokenMarket.id()`. +const TOKEN_MARKET_CONTRACT_ID: Identifier = Identifier::new([ + 137, 20, 125, 33, 9, 113, 160, 140, 158, 199, 2, 183, 75, 230, 71, 38, 213, 195, 65, 13, 129, + 32, 10, 91, 226, 116, 230, 164, 77, 101, 0, 147, +]); + #[cfg(test)] type QuorumInsertGate = (SyncSender<()>, Receiver<()>); @@ -1036,9 +1044,9 @@ impl ContextProvider for TrustedHttpContextProvider { platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { // The token market contract exists only from its activation version: below it no - // contract is served under its id, including one preloaded into the known contracts. - #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::TokenMarket.id() + // contract is served under its id, including one preloaded into the known contracts, + // whichever contract features this build has. + if *id == TOKEN_MARKET_CONTRACT_ID && platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { return Ok(None); @@ -1209,10 +1217,8 @@ impl ContextProvider for TrustedHttpContextProvider { } #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] - // Below protocol version 15 the token market contract is absent too. - if *id == SystemDataContract::TokenMarket.id() - && platform_version.protocol_version >= TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION - { + // Below protocol version 15 the lookup was refused before the known contracts. + if *id == SystemDataContract::TokenMarket.id() { return load_system_data_contract( SystemDataContract::TokenMarket, platform_version, @@ -2564,6 +2570,7 @@ mod tests { ) .unwrap(); let id = SystemDataContract::TokenMarket.id(); + assert_eq!(id, TOKEN_MARKET_CONTRACT_ID); assert!(provider .get_data_contract(&id, PlatformVersion::get(14).unwrap()) @@ -2588,6 +2595,54 @@ mod tests { .is_some()); } + /// A build without the token market feature still refuses a contract preloaded under the + /// token market's id before protocol version 15, and serves it from 15. + #[test] + fn should_refuse_a_preloaded_token_market_id_before_protocol_15_in_any_build() { + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::v1::DataContractV1; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::latest(); + let contract = DataContract::V1(DataContractV1 { + id: TOKEN_MARKET_CONTRACT_ID, + version: 1, + owner_id: Identifier::from([2u8; 32]), + document_types: BTreeMap::new(), + config: DataContractConfig::default_for_version(platform_version) + .expect("expected a default config"), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: BTreeMap::new(), + tokens: BTreeMap::new(), + keywords: vec![], + description: None, + }); + // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap() + .with_known_contracts(vec![contract]); + + assert!(provider + .get_data_contract(&TOKEN_MARKET_CONTRACT_ID, PlatformVersion::get(14).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + assert!(provider + .get_data_contract(&TOKEN_MARKET_CONTRACT_ID, platform_version) + .expect("the lookup must succeed from protocol version 15") + .is_some()); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost) From 5a98f00de2d37a568db2aec69d5f6c1f83b23ac9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 23:58:20 +0300 Subject: [PATCH 57/62] fix(sdk): the trusted provider's token market gate uses the canonical id The provider depends on data-contracts without any contract feature and checks the token market id through SystemDataContract::TokenMarket.id() in every build, instead of a copy of the id bytes. Only the system contract loader stays behind the contract features. The in-place edits to shipped generations now carry a comment saying why they are inert: calculate_total_tokens_balance 0 and 1 report no escrow, TotalTokensBalance::ok's escrow check cannot fire below 15, the block-end supply check's comment counts the escrow, and apply_drive_operations 1's forfeit_storage_refunds is pub(super) for generation 2 with its body unchanged. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + .../src/balances/total_tokens_balance/mod.rs | 3 +++ .../v0/mod.rs | 9 ++++---- .../calculate_total_tokens_balance/v0/mod.rs | 2 ++ .../calculate_total_tokens_balance/v1/mod.rs | 3 ++- .../apply_drive_operations/v1/mod.rs | 3 +++ .../Cargo.toml | 3 +++ .../src/provider.rs | 22 ++++++------------- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a03d30b500b..b34ee193129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6612,6 +6612,7 @@ dependencies = [ "arc-swap", "dash-async", "dash-context-provider", + "data-contracts", "dpp", "futures", "gloo-timers", diff --git a/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs b/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs index 36ca5ed1f4b..d29cc435c1b 100644 --- a/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs +++ b/packages/rs-dpp/src/balances/total_tokens_balance/mod.rs @@ -71,6 +71,9 @@ impl TotalTokensBalance { )); } + // Every protocol version runs this check. The escrow term is zero below protocol + // version 15, where `calculate_total_tokens_balance` 0 and 1 report none, so this + // refusal and its share of the sum below cannot change their verdict. if total_token_order_escrow_balances < 0 { return Err(ProtocolError::CriticalCorruptedCreditsCodeExecution( "Tokens in order escrow are less than 0".to_string(), diff --git a/packages/rs-drive-abci/src/execution/platform_events/tokens/validate_token_aggregated_balance/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/tokens/validate_token_aggregated_balance/v0/mod.rs index 6408070828f..d2b02d5b4a0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/tokens/validate_token_aggregated_balance/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/tokens/validate_token_aggregated_balance/v0/mod.rs @@ -21,10 +21,11 @@ impl Platform { if !token_balance.ok()? { // The balance side of the conservation equation includes the token shielded - // pools. Every protocol version selects this generation, and that is inert at - // the ones that predate those pools: below the token pool protocol version - // `calculate_total_tokens_balance` reports a pool total of zero, so - // `total_balances` is the identity balance total it always was. The line is on + // pools and the token order escrow. Every protocol version selects this + // generation, and that is inert at the ones that predate them: below the token + // pool protocol version `calculate_total_tokens_balance` reports a pool total of + // zero, and below 15 an escrow total of zero, so `total_balances` is the + // identity balance total it always was. The line is on // the diagnostic of an already-failed invariant in any case; the accept or // reject decision is `TotalTokensBalance::ok`. return Err(Error::Execution( diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs index a0285e39a58..e7eecb2e56d 100644 --- a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v0/mod.rs @@ -44,6 +44,8 @@ impl Drive { total_identity_token_balances, // Token shielded pools do not exist before protocol version 14. total_token_shielded_pool_balances: 0, + // v0 predates the token order escrow (protocol version 15): zero leaves the + // conservation check of every protocol version that selects v0 as it was. total_token_order_escrow_balances: 0, }) } diff --git a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs index c74dff226d8..89cfb39c925 100644 --- a/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs +++ b/packages/rs-drive/src/drive/tokens/calculate_total_tokens_balance/v1/mod.rs @@ -53,7 +53,8 @@ impl Drive { total_tokens_in_platform, total_identity_token_balances, total_token_shielded_pool_balances, - // v1 predates the token order escrow (protocol version 15). + // v1 predates the token order escrow (protocol version 15): zero leaves the + // conservation check of protocol version 14, which selects v1, as it was. total_token_order_escrow_balances: 0, }) } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index 8850d27fdb4..91de25642ac 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -262,6 +262,9 @@ impl Drive { /// for them. Given the costs of a batch in which only a moderator's deletion's document /// operations free bytes: the document's, whoever paid for it, its owner or an earlier one, and /// those of any index subtree the deletion empties, whoever created it. +/// +/// `pub(super)` so that generation 2 (protocol version 15) reuses it; the body is generation 1's, +/// unchanged, so protocol version 14 forfeits exactly as before. pub(super) fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { for operation in cost_operations.iter_mut() { let LowLevelDriveOperation::CalculatedCostOperation(cost) = operation else { diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index e3ac51fd5da..a6962e31478 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -10,6 +10,9 @@ description = "Trusted HTTP-based context provider for Dash Platform SDK" dash-async = { path = "../rs-dash-async" } dash-context-provider = { path = "../rs-context-provider" } +# The system data contract ids, without any contract: the provider refuses a contract under a +# system id before that contract exists, in every build. +data-contracts = { path = "../data-contracts", default-features = false } dpp = { path = "../rs-dpp", default-features = false, features = [ "dash-sdk-features", "bls-signatures", diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index a2780f0c717..6f82f36051e 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -7,6 +7,7 @@ use crate::types::{PreviousQuorumsResponse, QuorumData, QuorumsResponse}; use arc_swap::ArcSwap; use dash_context_provider::{ContextProvider, ContextProviderError, QuorumKeyFuture}; +use data_contracts::SystemDataContract; use dpp::bls::PublicKey; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; @@ -27,7 +28,7 @@ use dpp::data_contract::TokenConfiguration; feature = "token-market-contract", feature = "all-system-contracts" ))] -use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dpp::system_data_contracts::load_system_data_contract; #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; #[cfg(any( @@ -62,15 +63,6 @@ use url::Url; #[cfg(target_arch = "wasm32")] const WASM_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// The token market system contract's id (`SystemDataContract::TokenMarket`). It is spelled out -/// here so that every build, with or without the contract's feature, refuses to serve a contract -/// under this id before the contract exists (protocol version 15), a preloaded known contract -/// included. A test checks it against `SystemDataContract::TokenMarket.id()`. -const TOKEN_MARKET_CONTRACT_ID: Identifier = Identifier::new([ - 137, 20, 125, 33, 9, 113, 160, 140, 158, 199, 2, 183, 75, 230, 71, 38, 213, 195, 65, 13, 129, - 32, 10, 91, 226, 116, 230, 164, 77, 101, 0, 147, -]); - #[cfg(test)] type QuorumInsertGate = (SyncSender<()>, Receiver<()>); @@ -1046,7 +1038,7 @@ impl ContextProvider for TrustedHttpContextProvider { // The token market contract exists only from its activation version: below it no // contract is served under its id, including one preloaded into the known contracts, // whichever contract features this build has. - if *id == TOKEN_MARKET_CONTRACT_ID + if *id == SystemDataContract::TokenMarket.id() && platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { return Ok(None); @@ -2570,7 +2562,6 @@ mod tests { ) .unwrap(); let id = SystemDataContract::TokenMarket.id(); - assert_eq!(id, TOKEN_MARKET_CONTRACT_ID); assert!(provider .get_data_contract(&id, PlatformVersion::get(14).unwrap()) @@ -2605,8 +2596,9 @@ mod tests { use std::collections::BTreeMap; let platform_version = PlatformVersion::latest(); + let id = SystemDataContract::TokenMarket.id(); let contract = DataContract::V1(DataContractV1 { - id: TOKEN_MARKET_CONTRACT_ID, + id, version: 1, owner_id: Identifier::from([2u8; 32]), document_types: BTreeMap::new(), @@ -2634,11 +2626,11 @@ mod tests { .with_known_contracts(vec![contract]); assert!(provider - .get_data_contract(&TOKEN_MARKET_CONTRACT_ID, PlatformVersion::get(14).unwrap()) + .get_data_contract(&id, PlatformVersion::get(14).unwrap()) .expect("a pre-activation lookup must not error") .is_none()); assert!(provider - .get_data_contract(&TOKEN_MARKET_CONTRACT_ID, platform_version) + .get_data_contract(&id, platform_version) .expect("the lookup must succeed from protocol version 15") .is_some()); } From b4e1c6f633a23b6f02fa25b3b422eccc443ef179 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 12:08:39 +0300 Subject: [PATCH 58/62] feat(platform)!: token market orders no longer record a minimum fill The token's trade mode sets it; every fill reads it there. Co-Authored-By: Claude Opus 5.5 --- .../src/drive/tokens/order_escrow/mod.rs | 1 - packages/token-market-contract/README.md | 1 - .../v1/token-market-contract-documents.json | 20 +++---------------- packages/token-market-contract/src/lib.rs | 2 -- packages/token-market-contract/src/v1/mod.rs | 2 -- .../test/unit/tokenMarketContract.spec.js | 5 ++--- 6 files changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index 51825cbcc36..9eb393d9f6a 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -1065,7 +1065,6 @@ mod tests { ("price".to_string(), Value::I64(100)), ("amount".to_string(), Value::I64(remaining)), ("remaining".to_string(), Value::I64(remaining)), - ("minFill".to_string(), Value::I64(1)), ("decimals".to_string(), Value::I64(decimals)), ]), revision: Some(1), diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md index 89a488db9aa..1d699cff5a1 100644 --- a/packages/token-market-contract/README.md +++ b/packages/token-market-contract/README.md @@ -24,7 +24,6 @@ Tokens a holder offers for sale at a price. | `price` | integer, required | The credits the seller asks per whole token (10 to the power of `decimals` base units) | | `amount` | integer, required | The base units listed when the order was placed | | `remaining` | integer, required | The base units still for sale; the order is deleted when it is filled entirely | -| `minFill` | integer, required | The smallest fill the seller accepts, unless it takes everything still open; a fill may not leave less than this open either | | `decimals` | integer, required | The token's decimals when the order was placed. Every fill prices the order at this scale, so a later change to the token's decimals does not change what an open order costs or pays | ## `bid` diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json index 84fd73d14d8..470a05c0043 100644 --- a/packages/token-market-contract/schema/v1/token-market-contract-documents.json +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -80,18 +80,12 @@ "description": "The token amount, in base units, still open. The order is deleted when it is filled entirely", "position": 3 }, - "minFill": { - "type": "integer", - "minimum": 1, - "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", - "position": 4 - }, "decimals": { "type": "integer", "minimum": 0, "maximum": 255, "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", - "position": 5 + "position": 4 } }, "required": [ @@ -99,7 +93,6 @@ "price", "amount", "remaining", - "minFill", "decimals", "$createdAt", "$updatedAt" @@ -187,24 +180,18 @@ "description": "The token amount, in base units, still open. The order is deleted when it is filled entirely", "position": 3 }, - "minFill": { - "type": "integer", - "minimum": 1, - "description": "The smallest amount, in base units, a fill may take, unless it takes everything still open. A fill may not leave less than this open either", - "position": 4 - }, "escrow": { "type": "integer", "minimum": 0, "description": "The credits still held in escrow for the order. They cover what the open amount costs at the order's price; what is left once it is filled goes back to the buyer", - "position": 5 + "position": 4 }, "decimals": { "type": "integer", "minimum": 0, "maximum": 255, "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", - "position": 6 + "position": 5 } }, "required": [ @@ -212,7 +199,6 @@ "price", "amount", "remaining", - "minFill", "decimals", "escrow", "$createdAt", diff --git a/packages/token-market-contract/src/lib.rs b/packages/token-market-contract/src/lib.rs index c3c86f89adf..436f487cd99 100644 --- a/packages/token-market-contract/src/lib.rs +++ b/packages/token-market-contract/src/lib.rs @@ -107,7 +107,6 @@ mod tests { ask::properties::PRICE, ask::properties::AMOUNT, ask::properties::REMAINING, - ask::properties::MIN_FILL, ], ask::properties::REMAINING, ); @@ -118,7 +117,6 @@ mod tests { bid::properties::PRICE, bid::properties::AMOUNT, bid::properties::REMAINING, - bid::properties::MIN_FILL, bid::properties::ESCROW, ], bid::properties::ESCROW, diff --git a/packages/token-market-contract/src/v1/mod.rs b/packages/token-market-contract/src/v1/mod.rs index ead36928143..0c1af4cbe67 100644 --- a/packages/token-market-contract/src/v1/mod.rs +++ b/packages/token-market-contract/src/v1/mod.rs @@ -11,7 +11,6 @@ pub mod document_types { pub const PRICE: &str = "price"; pub const AMOUNT: &str = "amount"; pub const REMAINING: &str = "remaining"; - pub const MIN_FILL: &str = "minFill"; pub const DECIMALS: &str = "decimals"; } @@ -32,7 +31,6 @@ pub mod document_types { pub const PRICE: &str = "price"; pub const AMOUNT: &str = "amount"; pub const REMAINING: &str = "remaining"; - pub const MIN_FILL: &str = "minFill"; pub const ESCROW: &str = "escrow"; pub const DECIMALS: &str = "decimals"; } diff --git a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js index 5b853816375..5fa94864e42 100644 --- a/packages/token-market-contract/test/unit/tokenMarketContract.spec.js +++ b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js @@ -68,7 +68,6 @@ describe('Token Market Contract', () => { price: 100, amount: 1000, remaining: 1000, - minFill: 1, decimals: 2, ...extra, }); @@ -81,7 +80,7 @@ describe('Token Market Contract', () => { expect(validate(type, raw()).isValid()).to.be.true(); }); - ['tokenId', 'price', 'amount', 'remaining', 'minFill', 'decimals'].forEach((property) => { + ['tokenId', 'price', 'amount', 'remaining', 'decimals'].forEach((property) => { it(`should require ${property}`, () => { const document = raw(); delete document[property]; @@ -93,7 +92,7 @@ describe('Token Market Contract', () => { }); }); - ['price', 'amount', 'remaining', 'minFill'].forEach((property) => { + ['price', 'amount', 'remaining'].forEach((property) => { it(`should refuse a ${property} below 1`, () => { const error = expectJsonSchemaError(validate(type, raw({ [property]: 0 }))); From 6b2b8fd49553775ae167ae6cebb5f3215ca3a6b8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 12:56:09 +0300 Subject: [PATCH 59/62] feat(platform)!: generatedFrom sys.tokens.decimals generates a token's decimals (PV15) An integer property can be generated with sys.tokens.decimals, the decimals of the token an identifier property names. The value is read from state when the document is created and kept after, unless a replace names another token. The create and replace transformers read it (billed), clients leave it out, and a proof verifier takes it from the proved document. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords.md | 4 +- book/src/contract-keywords/generated-from.md | 56 +- book/src/data-model/documents.md | 8 + .../class_methods/try_from_schema/mod.rs | 237 +++++++- .../native_schema/properties.rs | 19 +- .../v3/generated_from_token_tests.rs | 448 +++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 + .../document_type/methods/mod.rs | 175 +++++- .../property/generated_from/mod.rs | 2 +- .../generated_from/system_function/mod.rs | 78 ++- .../generated_from/system_function/tokens.rs | 30 + .../document_type/property/mod.rs | 1 + .../serialized_version/v0/property.rs | 14 + .../batch/tests/document/generated_from.rs | 2 + .../tests/document/generated_from_token.rs | 538 ++++++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../batch/transformer/v0/mod.rs | 2 + .../v0/transformer.rs | 38 +- .../transformer.rs | 6 + .../v0/transformer.rs | 38 +- .../document_transition/mod.rs | 1 + .../state_generated_properties.rs | 113 ++++ .../v0/mod.rs | 17 +- .../dpp_versions/dpp_contract_versions/v7.rs | 6 +- .../rs-platform-version/src/version/v15.rs | 20 +- 25 files changed, 1799 insertions(+), 57 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/tokens.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_token.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/state_generated_properties.rs diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 0b92fa8d5c5..3ba6fc13dfb 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -173,8 +173,8 @@ Every key a contract can write, grouped by where it goes. **Since** is the proto | `encryptedFor.recipient` | identifier property path or `"$ownerId"` | The identity the value is encrypted to. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | | `encryptedFor.recipientKey`, `.senderKey` | integer property paths | The properties holding the recipient's and the sender's key ids. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | | `encryptedFor.scheme` | `"ecdh-secp256k1-aes256-cbc"` | How the ciphertext is made. | 14 | [The scheme](contract-keywords/encrypted-for.md#the-scheme) | -| `generatedFrom` | `{ "function", "params" }` | The platform generates the string from other properties of the document; on arrival when a document leaves it out. | 14 | [generatedFrom](contract-keywords/generated-from.md) · [internals](data-model/documents.md#generated-properties-generatedfrom) | -| `generatedFrom.function` | `"sys.stringTransformations.homographSafeASCII"` | The system function that generates the value: `sys.stringTransformations.` `lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase` or `homographSafeASCII`. | 14 | [Functions](contract-keywords/generated-from.md#functions) | +| `generatedFrom` | `{ "function", "params" }` | The platform generates the string from other properties of the document, on arrival when a document leaves it out; from 15 also an integer, a token's decimals read from state when the document is created and kept after. | 14 | [generatedFrom](contract-keywords/generated-from.md) · [internals](data-model/documents.md#generated-properties-generatedfrom) | +| `generatedFrom.function` | `"sys.stringTransformations.homographSafeASCII"` | The system function that generates the value: `sys.stringTransformations.` `lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase` or `homographSafeASCII`; from 15 `sys.tokens.decimals`, of the token an identifier property names. | 14 | [Functions](contract-keywords/generated-from.md#functions) | | `generatedFrom.params` | property paths | The properties the function reads, in order. | 14 | [Params](contract-keywords/generated-from.md#params) | | `refersTo` | a declaration | What an identifier points at, checked when a document is written. See the [keys](#refersto). | 14 | [References](contract-keywords/refers-to.md) · [internals](data-model/documents.md#document-references-refersto) | diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index a07cec16e02..4deb89f84da 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -2,14 +2,16 @@ `generatedFrom` says that the platform generates a string property's value with a system function of other properties of the same document, its params. The functions change the case of a string (`lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase`), or fold a name so that names differing only by case or by look-alike characters read the same (`homographSafeASCII`): reach for that one when a unique index must treat `Bob`, `BOB` and `B0B` as one name. A client may leave the property out, and the platform generates it when the document arrives; a client that sends it has it checked. The check reads only the document being written, so it costs no state reads. +From protocol version 15 an integer property can be generated too, with `sys.tokens.decimals`: the decimals of the token an identifier property names, read from the token's contract. A value read from state is generated when the document is created and kept after (see [Functions that read state](#functions-that-read-state)). + | | | |---|---| -| **Where** | A string property, at the top level or inside an object. Not on a typed array or its `items`, and not beside `$ref` | +| **Where** | A string property, at the top level or inside an object; from protocol version 15 also an integer property, for `sys.tokens.decimals`. Not on a typed array or its `items`, and not beside `$ref` | | **Value** | `{ "function": , "params": [, ...] }`: a system function, and as many params as it takes, each the dotted path of a property of the same document type (`"profile.display"` for a nested one), 1 to 256 characters (ASCII, as property names are, so as many bytes) | | **Default** | Absent: no rule | -| **Since** | protocol version 14 | +| **Since** | protocol version 14; `sys.tokens.decimals` protocol version 15 | | **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246); a property an update adds may declare it only when one of its params is new too (`DocumentTypeUpdateError`, 40212) | -| **Errors** | `DocumentPropertyNotGeneratedError` (10424) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231); on update `IncompatibleDocumentTypeSchemaError` (10246) or `DocumentTypeUpdateError` (40212) | +| **Errors** | `DocumentPropertyNotGeneratedError` (10424) on a document, and from protocol version 15 `ReferencedEntityNotFoundError` (40120) for a token that does not exist; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231); on update `IncompatibleDocumentTypeSchemaError` (10246) or `DocumentTypeUpdateError` (40212) | ## Example @@ -63,11 +65,49 @@ The string transformations change ASCII characters only and keep every other cha `lowercase`, `uppercase`, `capitalize` and `homographSafeASCII` keep the length of the value, and `camelCase` never lengthens it, but `snakeCase` can: `aBcDeF` becomes `a_bc_de_f`. Give a generated property a `maxLength` that admits what its function can generate from its params' longest value. +The `sys.tokens` functions (protocol version 15) each take one identifier, a token's id, and read the token's configuration: + +| Function | Returns | Property | +|---|---|---| +| `sys.tokens.decimals` | The token's decimals, from its conventions, 0 to 255 | An integer property whose `minimum` and `maximum`, when declared, admit 0 to 255, with no `enum` | + A function refuses nothing. Which characters a value may hold is the job of each param's `pattern`: `homographSafeASCII` resists look-alike names only where that pattern admits ASCII alone, as DPNS's does. A contract that admits other scripts can hold two names that look alike but generate different values. +### Functions that read state + +A token's decimals live in its contract and can change (a `Conventions` update by whoever controls them). So a property a `sys.tokens` function generates is generated when the document is created and kept after: + +```json +"order": { + "type": "object", + "properties": { + "tokenId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "price": { "type": "integer", "minimum": 1, "position": 1 }, + "decimals": { + "type": "integer", "minimum": 0, "maximum": 255, + "generatedFrom": { "function": "sys.tokens.decimals", "params": ["tokenId"] }, + "position": 2 + } + }, + "required": ["tokenId", "price", "decimals"], + "additionalProperties": false +} +``` + +- **Create.** The platform reads the token named by `tokenId` and writes its decimals into a document that leaves `decimals` out; a sent value must equal them. A token that does not exist refuses the document with `ReferencedEntityNotFoundError` (40120), naming the param. +- **Replace.** While the replace keeps `tokenId`, the document keeps the decimals it holds, whatever the token's decimals are now: a left-out value is the stored one, and a sent one must equal it. A replace that names another token reads that token's decimals, as a create does. +- **Cost.** Each read is billed to the write: the token's contract info, and its contract unless it is the document's own. +- **Clients.** A client holds no state, so it leaves the property out: the SDK's transition builders remove it, the document checks do not refuse it left out, and a proof verifier takes the value from the proved document, where the platform judged it. + +Here `price` is per whole token at the order's `decimals`, so a later change of the token's decimals does not reprice the order. + ## Params -For now a param is always a property of the same document, written as its dotted path. The form leaves room to add, in a later protocol version, literals (`{ "const": ... }`), system values such as `"$ownerId"` and nested calls, without changing what parses today. +For now a param is always a property of the same document, written as its dotted path: a string property for the string transformations, an identifier property (one carrying `refersTo` included) for the token functions. The form leaves room to add, in a later protocol version, literals (`{ "const": ... }`), system values such as `"$ownerId"` and nested calls, without changing what parses today. ## How it works @@ -75,20 +115,22 @@ For now a param is always a property of the same document, written as its dotted - **Checked after the JSON schema.** Wherever a document's properties are validated, on every create and replace included and in a client that validates a document before sending it, the property must hold what the function generates from its params, and must be absent when a param is. A document that repeats a key in an object on the way to the property or to a param is refused too, since the value it holds there would be ambiguous. A property that breaks this refuses the transition with `DocumentPropertyNotGeneratedError` (10424), which names the document type, the property, the function and its params. A schema error on any of the values is reported first. - **Absent params.** A property one of whose params is absent must be absent too. To make the params required in effect, list the generated property in `required`: a document without them then fails the schema. - **Replace.** A replace is judged on the whole new document. Leave the property out to have it generated from the new params; a stale value sent with changed params is refused. +- **Read from state.** A `sys.tokens` value is written or checked while the platform builds the write, before the rest, and kept from the document's creation: see [Functions that read state](#functions-that-read-state). - **Transfers, purchases and deletes by id** do not change the data and are not judged. An index-only delete is: its values are generated and checked like a create's, so a stale value, or one without its params, refuses it. The SDK's transition builders generate the property from the document's params, replacing any value the document holds and leaving it out when a param is absent, so a transition built from a document carries the value the platform would generate, and contest detection sees it. A document fetched, edited and sent back through them therefore carries the value of its new params, not the stale one. The property-constraint pre-checks of the JavaScript and FFI SDKs judge the document the same way. A client that validates a document it built, before building a transition, should generate it first (in Rust, `DocumentTypeBasicMethods::regenerate_generated_properties`) or set the value; otherwise the local check reports the property missing. The proof a client verifies after a create or replace is checked against the document with the generated value, as the platform stored it. ## Rules at registration -- The keyword is allowed only on a string property, and not beside `$ref`, whose definition replaces every keyword written next to it (declare it in the definition instead). On any other property, a typed array and its `items` included, the meta-schema refuses it (`JsonSchemaError`, 10101). +- The keyword is allowed only on a string property (from protocol version 15 also an integer property, for `sys.tokens.decimals`, whose `minimum` and `maximum` admit 0 to 255 and which declares no `enum`), and not beside `$ref`, whose definition replaces every keyword written next to it (declare it in the definition instead). On any other property, a typed array and its `items` included, the meta-schema refuses it (`JsonSchemaError`, 10101). - `function` must name a system function, and `params` must list 1 to 16 params, as many as the function takes. The meta-schema refuses an unknown function or an empty or overlong list (`JsonSchemaError`, 10101). -- Every param must name another string property of the same document type (not an object, not a system property, not the declaring property), and that property may not be generated itself. +- Every param must name another string property of the same document type (an identifier property for a `sys.tokens` function; not an object, not a system property, not the declaring property), and that property may not be generated itself. +- A `sys.tokens` function is refused on an [indexOnly](index-only.md) type, whose deletes send their values again where a kept value could not be checked, and on a property in a contested index, since a client, which leaves the property out, could not tell whether its document joins a contest. - Neither the declaring property nor a param may be [transient](transient.md) or sit inside a transient object: a transient value is never stored. - Every param must sit inside every object that holds the declaring property: a top-level property may take any param, but `profile.normalized` must take params inside `profile`. A document that supplies the params then always holds the object the platform writes the value into. - On a contract update, a new property may declare `generatedFrom` only when one of its params is new too. Documents stored before the update were never generated, so a new generated property whose params all existed is refused (`DocumentTypeUpdateError`, 40212). -The meta-schema refuses the shape errors of the first two rules with `JsonSchemaError` (10101). The parser refuses a function with the wrong number of params, and a declaration that breaks the param rules (the third to fifth), with `InvalidContractStructure` (10231). The update rule refuses with `DocumentTypeUpdateError` (40212). +The meta-schema (from protocol version 15, the native schema check) refuses the shape errors of the first two rules with `JsonSchemaError` (10101). The parser refuses a function with the wrong number of params, an integer property that cannot hold every value `sys.tokens.decimals` generates, and a declaration that breaks the param rules (the third to sixth), with `InvalidContractStructure` (10231). The update rule refuses with `DocumentTypeUpdateError` (40212). ## See also diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 1fc0babc1b3..5ae34b5a368 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -849,6 +849,14 @@ Three methods do the work at write time: Every call site was extended in place and is inert before protocol version 14: the `apply_generated_from`, `fill_generated_properties` and `validate_generated_from` slots are `None` there, and the meta-schemas refuse the keyword. In JavaScript the error reaches an app as `DocumentGeneratedFromErrorCode.DocumentPropertyNotGenerated`. +Protocol version 15 adds the first function that reads state, `sys.tokens.decimals` (`SystemFunction::Token(TokenFunction::Decimals)`, in `system_function::tokens`; `SystemFunction::reads_state` tells it apart). `apply_generated_from` 1 admits it on an integer property whose bounds admit 0 to 255, with an identifier param; generation 0, which protocol version 14 keeps, and meta-schema v3 refuse it, and the native schema check of protocol version 15 requires `type` to be `"integer"` beside it. `validate_generated_from_declarations` refuses it on an indexOnly type and on a property in a contested index. Its value cannot be generated from the document alone, so it is generated when the document is created and kept after: + +- `DocumentTypeBasicMethods::generate_state_read_properties` writes or checks every such property, given the replaced document's properties on a replace and a `read` of a token function's value. A replace that keeps the token keeps the stored value; a create, or a replace naming another token, reads it. It returns a sent value that differs as `DocumentPropertyNotGeneratedError`, and a token that does not exist as `ReferencedEntityNotFoundError` (state code 40120) naming the param. The Drive create and replace transformers call it right after `fill_generated_properties` (`document_transition::state_generated_properties`), reading the token's contract info (`fetch_token_contract_info_with_costs`) and, unless it is the document's own, its contract (`get_contract_with_fetch_info_and_fee`), and return the reads' fee with the action; a refusal bumps the nonce. The replace transformer takes the `Drive` and the transaction for it. +- `fill_generated_properties` leaves such a property alone, `regenerate_generated_properties` removes it (the transition builders send it left out), and `validate_generated_from_properties` judges it only for its absence when its param is absent. +- `adopt_state_generated_properties` copies the proved document's value into the document the proof verifier rebuilds from a create or replace transition, when the transition left it out (`verify_state_transition_was_executed_with_proof`). + +These were extended in place and are inert before protocol version 15, where no type declares such a function. A subscription filter (`matches_document_transition`) reads a transition's data as stored without it, since it reads no state. + ## Property Constraints (`propertyConstraints`) Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether an integer expression takes one of listed values, a comparison of a string property with string constants, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. 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 70c2ed49a3c..4fc4d8869b3 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 @@ -34,9 +34,9 @@ use crate::data_contract::document_type::{ EncryptedForRecipient, EncryptionScheme, GeneratedFrom, GenerationParam, IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, ListElementReference, LookupChooseFirst, LookupHashKey, LookupKeySource, PropertyReference, - ReferenceCombinator, ReferenceOperands, SystemFunction, WhereBoundRead, WhereCondition, - WhereContainsBound, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, WhereOrdering, - WhereOrderingBound, COMBINABLE_REFERENCE_TARGET_TYPES, + ReferenceCombinator, ReferenceOperands, SystemFunction, TokenFunction, WhereBoundRead, + WhereCondition, WhereContainsBound, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, + WhereOrdering, WhereOrderingBound, COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -2972,6 +2972,7 @@ fn apply_generated_from( { None => Ok(None), Some(0) => apply_generated_from_v0(inner_properties, property_type), + Some(1) => apply_generated_from_v1(inner_properties, property_type), Some(version) => Err(DataContractError::Unsupported(format!( "apply_generated_from version {version} is not supported" ))), @@ -3100,6 +3101,169 @@ fn apply_generated_from_v0( Ok(Some(GeneratedFrom { function, params })) } +/// Generation 1 (protocol version 15): generation 0, and a property may also be +/// generated with a `sys.tokens` function, which reads the configuration of the +/// token its parameter names. Such a property is an integer one: `sys.tokens.decimals` +/// generates the token's decimals, so the property's `minimum` and `maximum`, when it +/// declares them, admit 0 to 255, and it declares no `enum`. A string transformation +/// still generates a string property only. +fn apply_generated_from_v1( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + if let DocumentPropertyType::TypedArray(_) = property_type { + if inner_properties.contains_key(property_names::GENERATED_FROM) + || typed_array_items_keyword( + inner_properties, + property_names::GENERATED_FROM, + "has no parameters", + )? + .is_some() + { + return Err(DataContractError::InvalidContractStructure( + "generatedFrom is only allowed on string and integer properties, not on a typed \ + array or its items" + .to_string(), + )); + } + return Ok(None); + } + + let Some(generated_from_value) = inner_properties.get(property_names::GENERATED_FROM) else { + return Ok(None); + }; + + let shape_error = || { + DataContractError::InvalidContractStructure( + "generatedFrom must be an object with a function (its name) and params (the paths \ + of the properties of the same document type it reads)" + .to_string(), + ) + }; + let generated_from_map = generated_from_value + .to_btree_ref_string_map() + .map_err(|_| shape_error())?; + + for key in generated_from_map.keys() { + if !matches!( + key.as_str(), + property_names::FUNCTION | property_names::PARAMS + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom {key:?} is unknown, expected function and params" + ))); + } + } + + let generates_a_property = + |function: &SystemFunction| !matches!(function, SystemFunction::Hash(_)); + let function_name = generated_from_map + .get(property_names::FUNCTION) + .and_then(|value| value.as_text()) + .ok_or_else(shape_error)?; + let function = SystemFunction::from_wire_name(function_name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function_name:?} is unknown, expected one of {}", + SystemFunction::ALL + .iter() + .filter(|function| generates_a_property(function)) + .map(|function| format!("{:?}", function.as_str())) + .collect::>() + .join(", ") + )) + })?; + + // The property's type is the one its function generates; `sys.hash` belongs + // to the `refersTo` lookup keys + match function { + SystemFunction::StringTransformation(_) => { + if !matches!(property_type, DocumentPropertyType::String(_)) { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom is only allowed on string properties for function \ + {function}, which generates a string" + ))); + } + } + SystemFunction::Token(TokenFunction::Decimals) => { + let admits_every_decimals = match property_type { + DocumentPropertyType::U8 + | DocumentPropertyType::U16 + | DocumentPropertyType::U32 + | DocumentPropertyType::U64 + | DocumentPropertyType::U128 + | DocumentPropertyType::I16 + | DocumentPropertyType::I32 + | DocumentPropertyType::I64 + | DocumentPropertyType::I128 => { + let minimum = inner_properties + .get_optional_integer::(property_names::MINIMUM)? + .unwrap_or(i64::MIN); + let maximum = inner_properties + .get_optional_integer::(property_names::MAXIMUM)? + .unwrap_or(i64::MAX); + minimum <= 0 + && maximum >= u8::MAX as i64 + && !inner_properties.contains_key(property_names::ENUM) + } + _ => false, + }; + if !admits_every_decimals { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} generates a token's decimals, so it is \ + only allowed on an integer property that admits every value from 0 to 255 \ + (no minimum above 0, no maximum below 255, no enum)" + ))); + } + } + SystemFunction::Hash(_) => { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} does not generate a string or an integer: a \ + property is generated with a sys.stringTransformations or sys.tokens function" + ))); + } + } + let Some(parameter_count) = function.parameter_count() else { + return Err(shape_error()); + }; + + let params = generated_from_map + .get(property_names::PARAMS) + .and_then(|value| value.as_array()) + .ok_or_else(shape_error)?; + if params.len() != parameter_count { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} takes {parameter_count} parameter(s), but params \ + lists {}", + params.len() + ))); + } + let params = params + .iter() + .map(|param| { + let path = param.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "generatedFrom params must be property paths (strings)".to_string(), + ) + })?; + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom params must be between 1 and {MAX_PROPERTY_PATH_LENGTH} \ + characters" + ))); + } + if path.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom params must name properties of the document type, not \ + system property \"{path}\"" + ))); + } + Ok(GenerationParam::Property(path.to_string())) + }) + .collect::, _>>()?; + + Ok(Some(GeneratedFrom { function, params })) +} + /// Checks every `generatedFrom` declaration of a document type against the /// properties its parameters name, once all of them are parsed. Each /// parameter: @@ -3117,6 +3281,14 @@ fn apply_generated_from_v0( /// top-level property may take any parameter), so a document supplying the /// parameters always holds the object the platform writes the value into. /// +/// A `sys.tokens` function (protocol version 15) reads an identifier property +/// instead, the token's id. Since it reads state, the value is generated when a +/// document is created and kept after, so the type may not be indexOnly (an +/// indexOnly delete supplies its values again, which the platform could not +/// check against a kept value), and the property may not be in a contested index +/// (a client, which holds no state, leaves the property out and could not tell +/// whether the document joins a contest). +/// /// Owned by parser generation 3: the only generation that admits the keyword. pub(super) fn validate_generated_from_declarations( document_type: &DocumentTypeV2, @@ -3142,6 +3314,31 @@ pub(super) fn validate_generated_from_declarations( )); } + if generated_from.function.reads_state() { + if document_type.index_only { + return Err(structure_error(format!( + "function {} reads state, so its value is kept from the document's creation, \ + and an indexOnly type keeps no document: generate it on a type that stores \ + its documents", + generated_from.function + ))); + } + if let Some(index) = document_type.indices.values().find(|index| { + index.contested_index.is_some() + && index + .properties + .iter() + .any(|property| &property.name == path) + }) { + return Err(structure_error(format!( + "function {} reads state, which a client cannot, so the property may not be \ + in contested index \"{}\": the client could not tell whether a document \ + joins a contest", + generated_from.function, index.name + ))); + } + } + for param in generated_from.property_params() { if param == path { return Err(structure_error( @@ -3161,14 +3358,32 @@ pub(super) fn validate_generated_from_declarations( "param \"{param}\" is not a property of the document type" ))); }; - if !matches!( - param_property.property_type, - DocumentPropertyType::String(_) - ) { - return Err(structure_error(format!( - "param \"{param}\" has type {}, not string", - param_property.property_type.name() - ))); + match generated_from.function { + SystemFunction::Token(_) => { + if !matches!( + param_property.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(structure_error(format!( + "param \"{param}\" has type {}, not identifier: function {} reads \ + the token whose id it holds", + param_property.property_type.name(), + generated_from.function + ))); + } + } + SystemFunction::StringTransformation(_) | SystemFunction::Hash(_) => { + if !matches!( + param_property.property_type, + DocumentPropertyType::String(_) + ) { + return Err(structure_error(format!( + "param \"{param}\" has type {}, not string", + param_property.property_type.name() + ))); + } + } } if param_property.generated_from.is_some() { return Err(structure_error(format!( diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs index 59bf36fbe3a..26dcaf675e0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs @@ -270,9 +270,23 @@ impl Checker<'_> { self.required(map, &["type"])?; self.member_constant(map, "type", json!("string"))?; } - if has("generatedFrom") { + if let Some(generated_from) = map.get("generatedFrom") { + // A `sys.tokens` function (protocol version 15, which the meta-schema predates) + // generates an integer; every other function a string + let generates_an_integer = generated_from + .get("function") + .and_then(JsonValue::as_str) + .is_some_and(|function| function.starts_with("sys.tokens.")); self.required(map, &["type"])?; - self.member_constant(map, "type", json!("string"))?; + self.member_constant( + map, + "type", + json!(if generates_an_integer { + "integer" + } else { + "string" + }), + )?; if has("$ref") { return Err(self.not_error( json!({ "required": ["$ref"] }), @@ -405,6 +419,7 @@ impl Checker<'_> { "sys.stringTransformations.lowercase", "sys.stringTransformations.snakeCase", "sys.stringTransformations.uppercase", + "sys.tokens.decimals", ], ) })?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs new file mode 100644 index 00000000000..a80714c39e8 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs @@ -0,0 +1,448 @@ +//! `generatedFrom` with a function that reads state (protocol version 15): +//! `sys.tokens.decimals` generates an integer property, the decimals of the +//! token an identifier property names. The parse is `apply_generated_from` 1 +//! and the checks against the rest of the type are +//! `validate_generated_from_declarations`. The value is generated when the +//! document is created and kept after (`generate_state_read_properties`), a +//! client leaves it out (`regenerate_generated_properties`), and a proof +//! verifier takes it from the proved document +//! (`adopt_state_generated_properties`). + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::data_contract::document_type::{ + GeneratedFrom, GenerationParam, SystemFunction, TokenFunction, +}; +use platform_value::platform_value; +use std::convert::Infallible; + +const DECIMALS: &str = "sys.tokens.decimals"; + +fn identifier_property(position: u64) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +fn decimals_property(position: u64) -> Value { + platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 255, + "generatedFrom": { "function": DECIMALS, "params": ["tokenId"] }, + "position": position + }) +} + +/// An `order` type: the `tokenId` it is for, and that token's `decimals`. +fn order_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "tokenId": identifier_property(0), + "decimals": decimals_property(1), + "label": { "type": "string", "maxLength": 32, "position": 2 } + }, + "required": ["tokenId", "decimals"], + "additionalProperties": false + }) +} + +/// [`order_schema`] with `decimals` replaced by `decimals`. +fn order_schema_with_decimals(decimals: Value) -> Value { + let mut schema = order_schema(); + schema + .get_mut("properties") + .expect("properties") + .expect("properties are present") + .insert("decimals".to_string(), decimals) + .expect("properties are a map"); + schema +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") +} + +fn token(byte: u8) -> Value { + Value::Identifier([byte; 32]) +} + +fn data(entries: Vec<(&str, Value)>) -> BTreeMap { + entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect() +} + +/// A read of the token's decimals: 6 for token 5, 18 for token 6, none for any other. +fn read_decimals( + function: TokenFunction, + token_id: Identifier, +) -> Result, Infallible> { + assert_eq!(function, TokenFunction::Decimals); + Ok(match token_id.to_buffer() { + [5, ..] => Some(Value::U8(6)), + [6, ..] => Some(Value::U8(18)), + _ => None, + }) +} + +/// A read that must not happen: the value is kept. +fn no_read(_: TokenFunction, _: Identifier) -> Result, Infallible> { + panic!("a kept value is not read from state") +} + +// ================================================================ +// Parse +// ================================================================ + +#[test] +fn should_parse_sys_tokens_decimals_onto_an_integer_property_from_protocol_version_15() { + let document_type = parse(order_schema()); + assert_eq!( + document_type + .as_ref() + .flattened_properties() + .get("decimals") + .expect("decimals is parsed") + .generated_from, + Some(GeneratedFrom { + function: SystemFunction::Token(TokenFunction::Decimals), + params: vec![GenerationParam::Property("tokenId".to_string())], + }) + ); + // An identifier carrying a `refersTo` names the token as well + let mut schema = order_schema(); + schema + .get_mut("properties") + .expect("properties") + .expect("properties are present") + .get_mut("tokenId") + .expect("tokenId") + .expect("tokenId is present") + .insert("refersTo".to_string(), platform_value!({ "type": "token" })) + .expect("tokenId is a map"); + parse(schema); +} + +#[test] +fn should_refuse_sys_tokens_decimals_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + // Protocol version 14's meta-schema refuses the function, and its parser refuses a + // generated integer property + expect_json_schema_error(parse_dispatched(order_schema(), platform_version, true)); + expect_structure_error( + parse_dispatched(order_schema(), platform_version, false), + "only allowed on string properties", + ); +} + +/// Both parses refuse the declaration: the validating one natively or with the parser's +/// structure error, the one that skips the schema check with the parser's. +fn expect_refused(schema: Value, needle: &str) { + assert!( + parse_dispatched(schema.clone(), PlatformVersion::latest(), true).is_err(), + "a validating parse refuses it ({needle})" + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + needle, + ); +} + +#[test] +fn should_refuse_sys_tokens_decimals_on_a_property_that_cannot_hold_every_decimals() { + let generated = platform_value!({ "function": DECIMALS, "params": ["tokenId"] }); + for (decimals, needle) in [ + ( + platform_value!({ "type": "string", "maxLength": 8, "generatedFrom": generated.clone(), "position": 1 }), + "only allowed on an integer property that admits every value from 0 to 255", + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100, "generatedFrom": generated.clone(), "position": 1 }), + "only allowed on an integer property that admits every value from 0 to 255", + ), + ( + platform_value!({ "type": "integer", "minimum": 1, "maximum": 255, "generatedFrom": generated.clone(), "position": 1 }), + "only allowed on an integer property that admits every value from 0 to 255", + ), + ( + platform_value!({ "type": "integer", "enum": [0, 2, 8], "generatedFrom": generated.clone(), "position": 1 }), + "only allowed on an integer property that admits every value from 0 to 255", + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 255, "generatedFrom": { "function": DECIMALS, "params": ["tokenId", "label"] }, "position": 1 }), + "takes 1 parameter(s), but params lists 2", + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 255, "generatedFrom": { "function": DECIMALS, "params": ["label"] }, "position": 1 }), + "param \"label\" has type string, not identifier", + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 255, "generatedFrom": { "function": "sys.stringTransformations.lowercase", "params": ["label"] }, "position": 1 }), + "only allowed on string properties for function sys.stringTransformations.lowercase", + ), + ] { + expect_refused(order_schema_with_decimals(decimals), needle); + } + // An unbounded integer admits every decimals + parse(order_schema_with_decimals(platform_value!({ + "type": "integer", + "generatedFrom": generated, + "position": 1 + }))); +} + +#[test] +fn should_refuse_sys_tokens_decimals_on_an_index_only_type() { + let schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [ + { + "name": "byToken", + "properties": [{ "tokenId": "asc" }, { "decimals": "asc" }], + "terminal": "$ownerId" + } + ], + "properties": { + "tokenId": identifier_property(0), + "decimals": decimals_property(1) + }, + "required": ["tokenId", "decimals"], + "additionalProperties": false + }); + expect_refused(schema, "an indexOnly type keeps no document"); +} + +#[test] +fn should_refuse_sys_tokens_decimals_in_a_contested_index() { + let schema = platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byLabel", + "properties": [{ "label": "asc" }, { "decimals": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [{ "field": "label", "regexPattern": "^[a-z]{3,5}$" }], + "resolution": 0 + } + } + ], + "properties": { + "tokenId": identifier_property(0), + "decimals": decimals_property(1), + "label": { "type": "string", "maxLength": 20, "position": 2 } + }, + "required": ["tokenId", "decimals", "label"], + "additionalProperties": false + }); + expect_refused(schema, "may not be in contested index \"byLabel\""); +} + +// ================================================================ +// Generation from state +// ================================================================ + +#[test] +fn should_write_the_token_decimals_on_a_create_that_leaves_them_out() { + let document_type = parse(order_schema()); + let mut properties = data(vec![("tokenId", token(5))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, None, read_decimals) + .expect("the reads succeed"); + assert_eq!(refusal, None); + assert_eq!( + properties, + data(vec![("tokenId", token(5)), ("decimals", Value::U8(6))]) + ); +} + +#[test] +fn should_check_decimals_a_create_sends() { + let document_type = parse(order_schema()); + + // The right value, at any integer width, passes as sent + let mut properties = data(vec![("tokenId", token(5)), ("decimals", Value::I64(6))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, None, read_decimals) + .expect("the reads succeed"); + assert_eq!(refusal, None); + assert_eq!(properties.get("decimals"), Some(&Value::I64(6))); + + // Another value is refused + let mut properties = data(vec![("tokenId", token(5)), ("decimals", Value::U8(2))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, None, read_decimals) + .expect("the reads succeed"); + match refusal { + Some(ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(error))) => { + assert_eq!(error.property(), "decimals"); + assert_eq!(error.function(), DECIMALS); + } + other => panic!("expected the property to be refused, got {other:?}"), + } +} + +#[test] +fn should_refuse_a_token_that_does_not_exist() { + let document_type = parse(order_schema()); + let mut properties = data(vec![("tokenId", token(9))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, None, read_decimals) + .expect("the reads succeed"); + match refusal { + Some(ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(error))) => { + assert_eq!(error.entity_id(), &Identifier::new([9; 32])); + assert_eq!(error.path(), "tokenId"); + } + other => panic!("expected the token to be refused, got {other:?}"), + } +} + +#[test] +fn should_keep_the_stored_decimals_on_a_replace_that_keeps_the_token() { + let document_type = parse(order_schema()); + // Stored when the token had 2 decimals; it has 6 now + let stored = data(vec![("tokenId", token(5)), ("decimals", Value::U8(2))]); + + let mut properties = data(vec![ + ("tokenId", token(5)), + ("label", Value::Text("x".into())), + ]); + let refusal = document_type + .generate_state_read_properties(&mut properties, Some(&stored), no_read) + .expect("nothing is read"); + assert_eq!(refusal, None); + assert_eq!(properties.get("decimals"), Some(&Value::U8(2))); + + let mut properties = data(vec![("tokenId", token(5)), ("decimals", Value::U8(2))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, Some(&stored), no_read) + .expect("nothing is read"); + assert_eq!(refusal, None); + + // The token's current decimals are not the kept value + let mut properties = data(vec![("tokenId", token(5)), ("decimals", Value::U8(6))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, Some(&stored), no_read) + .expect("nothing is read"); + assert!(matches!( + refusal, + Some(ConsensusError::BasicError( + BasicError::DocumentPropertyNotGeneratedError(_) + )) + )); +} + +#[test] +fn should_read_the_decimals_again_on_a_replace_that_names_another_token() { + let document_type = parse(order_schema()); + let stored = data(vec![("tokenId", token(5)), ("decimals", Value::U8(6))]); + let mut properties = data(vec![("tokenId", token(6))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, Some(&stored), read_decimals) + .expect("the reads succeed"); + assert_eq!(refusal, None); + assert_eq!(properties.get("decimals"), Some(&Value::U8(18))); + + // The stored token's decimals are refused for the new one + let mut properties = data(vec![("tokenId", token(6)), ("decimals", Value::U8(6))]); + let refusal = document_type + .generate_state_read_properties(&mut properties, Some(&stored), read_decimals) + .expect("the reads succeed"); + assert!(refusal.is_some()); +} + +#[test] +fn should_leave_a_document_without_its_token_alone() { + let document_type = parse(order_schema()); + for sent in [ + data(vec![]), + data(vec![("decimals", Value::U8(6))]), + // Not an identifier: the schema validation refuses it + data(vec![("tokenId", Value::Text("token".into()))]), + ] { + let mut properties = sent.clone(); + let refusal = document_type + .generate_state_read_properties(&mut properties, None, no_read) + .expect("nothing is read"); + assert_eq!(refusal, None); + assert_eq!(properties, sent); + } +} + +// ================================================================ +// Clients and proofs +// ================================================================ + +#[test] +fn should_leave_the_value_to_the_platform_on_a_client() { + let document_type = parse(order_schema()); + let platform_version = PlatformVersion::latest(); + + // The transition builders remove it, whatever the document holds + let mut properties = data(vec![("tokenId", token(5)), ("decimals", Value::U8(6))]); + document_type + .regenerate_generated_properties(&mut properties, platform_version) + .expect("the regeneration runs"); + assert_eq!(properties, data(vec![("tokenId", token(5))])); + + // The fill on arrival leaves it to the state read + document_type + .fill_generated_properties(&mut properties, platform_version) + .expect("the fill runs"); + assert_eq!(properties, data(vec![("tokenId", token(5))])); + + // The document check does not refuse it left out, and still refuses it without its token + let check = |properties: BTreeMap| { + document_type + .as_ref() + .validate_generated_from_properties(&Value::from(properties), platform_version) + .expect("the check runs") + }; + assert!(check(data(vec![("tokenId", token(5))])).is_valid()); + assert!(check(data(vec![ + ("tokenId", token(5)), + ("decimals", Value::U8(6)) + ])) + .is_valid()); + assert!(!check(data(vec![("decimals", Value::U8(6))])).is_valid()); +} + +#[test] +fn should_take_the_proved_value_into_a_rebuilt_document() { + let document_type = parse(order_schema()); + let proved = data(vec![("tokenId", token(5)), ("decimals", Value::U8(6))]); + + let mut rebuilt = data(vec![("tokenId", token(5))]); + document_type.adopt_state_generated_properties(&mut rebuilt, &proved); + assert_eq!(rebuilt, proved); + + // A value the transition sent stays, so a proved document holding another fails the + // comparison + let mut rebuilt = data(vec![("tokenId", token(5)), ("decimals", Value::U8(2))]); + document_type.adopt_state_generated_properties(&mut rebuilt, &proved); + assert_eq!(rebuilt.get("decimals"), Some(&Value::U8(2))); + + // Without its token nothing is taken + let mut rebuilt = data(vec![]); + document_type.adopt_state_generated_properties(&mut rebuilt, &proved); + assert!(rebuilt.is_empty()); +} 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 e13e4ee6560..c585ce1271a 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 @@ -2020,6 +2020,8 @@ mod summable_off_count_index_tests; #[cfg(all(test, feature = "validation"))] mod generated_from_tests; +#[cfg(all(test, feature = "validation"))] +mod generated_from_token_tests; #[cfg(test)] mod keep_history_tests; #[cfg(all(test, feature = "validation"))] 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 66f9ea9084b..31273690c5b 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 @@ -14,11 +14,13 @@ use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; use crate::ProtocolError; +use crate::consensus::basic::document::DocumentPropertyNotGeneratedError; #[cfg(feature = "validation")] use crate::consensus::basic::document::{ - DocumentPropertyMaxBytesExceededError, DocumentPropertyNotGeneratedError, - InvalidEncryptedPropertyShapeError, + DocumentPropertyMaxBytesExceededError, InvalidEncryptedPropertyShapeError, }; +use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; +use crate::consensus::ConsensusError; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; @@ -26,9 +28,12 @@ use crate::data_contract::document_type::methods::versioned_methods::DocumentTyp use crate::data_contract::document_type::property_constraints::{ DocumentSystemValues, SystemChange, }; -use crate::data_contract::document_type::DocumentPropertyType; #[cfg(feature = "validation")] use crate::data_contract::document_type::StringPropertySizes; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, GeneratedFrom, SystemFunction, + TokenFunction, +}; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; use platform_value::btreemap_extensions::{ @@ -320,6 +325,132 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { Ok(Cow::Owned(stored)) } + /// Writes or checks every `generatedFrom` property of `data` whose function reads state + /// (`sys.tokens`, protocol version 15), where `fill_generated_properties` writes the + /// others. Such a value is generated when the document is created and kept after: + /// + /// * on a create (`stored` is `None`), and on a replace that changes the token the + /// parameter names, the value is `read` from state: a left-out property is written, + /// and a sent one must equal it; + /// * on a replace that keeps the token (`stored`, the replaced document's properties, + /// names the same one), the stored value is kept: a left-out property is written with + /// it, and a sent one must equal it. + /// + /// Returns the refusal of the first property that does not pass: a sent value other than + /// the one generated or kept (`DocumentPropertyNotGeneratedError`), or a token that does + /// not exist (`ReferencedEntityNotFoundError`, naming the parameter). Leaves alone a + /// property whose parameter is absent, repeated or not an identifier, and one whose path + /// repeats a key: the schema validation and `validate_generated_from_properties` refuse + /// those on their own. + /// + /// `read` returns the value of a function for a token, `None` when the token does not + /// exist; the platform reads it from state, billing the reads to the write. Only the + /// platform calls this: a client holds no state and leaves such a property out + /// (`regenerate_generated_properties` removes it). + /// + /// Inert on a type without such a declaration, which covers every type parsed before + /// protocol version 15 (generation 1 of `apply_generated_from` is the first to admit a + /// `sys.tokens` function). + fn generate_state_read_properties( + &self, + data: &mut BTreeMap, + stored: Option<&BTreeMap>, + mut read: impl FnMut(TokenFunction, Identifier) -> Result, E>, + ) -> Result, E> + where + Self: DocumentTypeV2Getters, + { + for (path, generated_from) in self.generated_from_fields() { + let SystemFunction::Token(function) = generated_from.function else { + continue; + }; + let Some(param) = generated_from.property_params().next() else { + continue; + }; + let (Ok(sent), Ok(Some(token))) = ( + read_data_at_path(data, path), + read_data_at_path(data, param), + ) else { + continue; + }; + let Ok(token_id) = token.to_identifier() else { + continue; + }; + let sent = sent.cloned(); + let kept = stored.and_then(|stored| { + let stored_token = read_data_at_path(stored, param).ok()??; + (stored_token.to_identifier().ok()? == token_id) + .then(|| read_data_at_path(stored, path).ok().flatten().cloned()) + .flatten() + }); + let value = match kept { + Some(kept) => kept, + None => match read(function, token_id)? { + Some(value) => value, + None => { + return Ok(Some( + ReferencedEntityNotFoundError::new( + token_id, + DocumentPropertyReferenceTarget::Token, + param.to_string(), + ) + .into(), + )) + } + }, + }; + match sent { + None => { + // Registration puts the parameter inside every object that holds the + // property, so the objects on the way are maps: the parameter was read + // through them. If the insert ever failed, the property would stay + // absent and the schema validation would judge the document without it. + let _ = data.insert_at_path(path, value); + } + Some(sent) => { + if !same_integer(&sent, &value) { + return Ok(Some(not_generated(self.name(), path, generated_from))); + } + } + } + } + Ok(None) + } + + /// Writes into `data` (a document rebuilt from a create or replace transition) every + /// property generated from state (`sys.tokens`) that it leaves out while supplying the + /// parameter, as `stored` (the document as the platform stored it) holds it. A proof + /// verifier rebuilds the document a transition wrote to compare it with the proved one, + /// and holds no state: it takes such a value from the proved document, which the + /// platform judged when it wrote it (`generate_state_read_properties`). A value the + /// transition sent stays, so a proved document holding another fails the comparison. + /// + /// Inert on a type without such a declaration, which covers every type parsed before + /// protocol version 15. + fn adopt_state_generated_properties( + &self, + data: &mut BTreeMap, + stored: &BTreeMap, + ) where + Self: DocumentTypeV2Getters, + { + for (path, generated_from) in self.generated_from_fields() { + if !generated_from.function.reads_state() { + continue; + } + let params_supplied = generated_from + .property_params() + .all(|param| matches!(read_data_at_path(data, param), Ok(Some(_)))); + if !params_supplied || !matches!(read_data_at_path(data, path), Ok(None)) { + continue; + } + if let Ok(Some(value)) = read_data_at_path(stored, path) { + let value = value.clone(); + let _ = data.insert_at_path(path, value); + } + } + } + /// `fill_generated_properties` (`replace_present` false) and /// `regenerate_generated_properties` (`replace_present` true). fn generate_properties( @@ -398,7 +529,10 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { /// A document that arrived at the platform has been through /// `fill_generated_properties`, so a left-out property whose parameters are present is /// already written; one that has not (a client validating a document before sending - /// it) is refused for the missing property. + /// it) is refused for the missing property. A property whose function reads state + /// (`sys.tokens`) is judged here only for its absence when a parameter is absent: the + /// platform judged its value when it built the write + /// (`generate_state_read_properties`), and a client cannot. /// /// Versioned on `validate_generated_from` in the document type method versions: `None` /// before protocol version 14 returns an empty result, which keeps the shipped document @@ -450,6 +584,13 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { if arguments.iter().any(Option::is_none) { // Nothing to generate from: the property must be left out too value.is_none() + } else if generated_from.function.reads_state() { + // Generated from state (protocol version 15, inert before: no type + // parsed earlier declares such a function): the platform wrote or + // checked it when it built the write + // (`generate_state_read_properties`), and a client, which holds no + // state, leaves it out + true } else { let texts: Option> = arguments .iter() @@ -507,6 +648,32 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { } } +/// Whether two values hold the same integer, whatever width each is stored at. +fn same_integer(a: &Value, b: &Value) -> bool { + match (a.to_integer::(), b.to_integer::()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// The refusal of `path`, a property `generated_from` generates, holding another value. +fn not_generated( + document_type: &str, + path: &str, + generated_from: &GeneratedFrom, +) -> ConsensusError { + DocumentPropertyNotGeneratedError::new( + document_type.to_string(), + path.to_string(), + generated_from.function.as_str().to_string(), + generated_from + .property_params() + .map(str::to_string) + .collect(), + ) + .into() +} + /// A map on the way to a path holds the path's key more than once. pub struct RepeatedKey; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs index 04433f141ec..2bcecdd49a3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs @@ -11,7 +11,7 @@ pub mod system_function; -pub use system_function::{HashFunction, StringTransformation, SystemFunction}; +pub use system_function::{HashFunction, StringTransformation, SystemFunction, TokenFunction}; use serde::{Deserialize, Serialize}; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs index c727c5847a5..77eed91fd80 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs @@ -4,14 +4,18 @@ //! `sys.stringTransformations` is [`StringTransformation`], in //! [`string_transformations`], which a `generatedFrom` property is generated //! with, and `sys.hash` is [`HashFunction`], in [`hash`], which a `refersTo` -//! lookup key is computed with. Each keyword takes the functions of its own -//! namespace only. +//! lookup key is computed with. `sys.tokens` is [`TokenFunction`], in +//! [`tokens`], which a `generatedFrom` property is generated with from protocol +//! version 15: unlike the string transformations, its functions read state. +//! Each keyword takes the functions of its own namespaces only. pub mod hash; pub mod string_transformations; +pub mod tokens; pub use hash::HashFunction; pub use string_transformations::StringTransformation; +pub use tokens::TokenFunction; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -22,21 +26,26 @@ use std::fmt; /// A closed list of built-ins. Every node must compute exactly the same value, /// so each function is defined without any table that could differ between /// builds (Unicode case mappings change between Rust releases): the string -/// transformations change ASCII characters only. +/// transformations change ASCII characters only, and the token functions read +/// committed state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemFunction { /// `sys.stringTransformations.*`: one string to a string. StringTransformation(StringTransformation), /// `sys.hash.*`: one or more parameters, their bytes joined, to a hash. Hash(HashFunction), + /// `sys.tokens.*`: one token id to what the token's configuration holds + /// (protocol version 15). + Token(TokenFunction), } impl SystemFunction { - /// Every function, in wire-name order: the hashes, then the string - /// transformations. - pub const ALL: [SystemFunction; HashFunction::ALL.len() + StringTransformation::ALL.len()] = { + /// Every function, in wire-name order: the hashes, the string + /// transformations, then the token functions. + pub const ALL: [SystemFunction; + HashFunction::ALL.len() + StringTransformation::ALL.len() + TokenFunction::ALL.len()] = { let mut all = [SystemFunction::Hash(HashFunction::ALL[0]); - HashFunction::ALL.len() + StringTransformation::ALL.len()]; + HashFunction::ALL.len() + StringTransformation::ALL.len() + TokenFunction::ALL.len()]; let mut index = 0; while index < HashFunction::ALL.len() { all[index] = SystemFunction::Hash(HashFunction::ALL[index]); @@ -49,6 +58,12 @@ impl SystemFunction { index += 1; transformation += 1; } + let mut token = 0; + while token < TokenFunction::ALL.len() { + all[index] = SystemFunction::Token(TokenFunction::ALL[token]); + index += 1; + token += 1; + } all }; @@ -58,6 +73,7 @@ impl SystemFunction { match self { SystemFunction::StringTransformation(transformation) => transformation.as_str(), SystemFunction::Hash(hash) => hash.as_str(), + SystemFunction::Token(token) => token.as_str(), } } @@ -69,17 +85,28 @@ impl SystemFunction { } /// How many parameters the function takes: a string transformation one - /// string, `None` for a hash, which takes one or more. + /// string, a token function one token id, `None` for a hash, which takes + /// one or more. pub const fn parameter_count(&self) -> Option { match self { - SystemFunction::StringTransformation(_) => Some(1), + SystemFunction::StringTransformation(_) | SystemFunction::Token(_) => Some(1), SystemFunction::Hash(_) => None, } } + /// Whether the function reads state: a token function reads the token's + /// configuration. A property such a function generates is generated when + /// the document is created, and again only when a replace changes a + /// parameter; otherwise the document keeps the value it holds. Only the + /// platform can generate it, so a client leaves it out. + pub const fn reads_state(&self) -> bool { + matches!(self, SystemFunction::Token(_)) + } + /// The string value for `arguments`, the parameters' values in `params` /// order. `None` when their count is not the function's, which registration - /// rules out, and for a function that does not return a string (a hash). + /// rules out, and for a function that does not return a string from + /// strings (a hash, or a token function, which reads state). /// The platform writes this value into a document that leaves the property /// out, and compares a sent value with it. pub fn apply(&self, arguments: &[&str]) -> Option { @@ -148,7 +175,16 @@ mod tests { assert_eq!(function.parameter_count(), None, "{function}"); assert_eq!(function.apply(&["a"]), None, "{function}"); } + SystemFunction::Token(_) => { + assert_eq!(function.parameter_count(), Some(1), "{function}"); + assert_eq!(function.apply(&["a"]), None, "{function}"); + } } + assert_eq!( + function.reads_state(), + matches!(function, SystemFunction::Token(_)), + "{function}" + ); assert_eq!(function.apply(&[]), None, "{function}"); assert_eq!(function.apply(&["a", "b"]), None, "{function}"); } @@ -170,6 +206,7 @@ mod tests { "sys.stringTransformations.lowercase", "sys.stringTransformations.snakeCase", "sys.stringTransformations.uppercase", + "sys.tokens.decimals", ] ); } @@ -197,10 +234,12 @@ mod tests { assert!(serde_json::from_value::(serde_json::json!("sys.nope")).is_err()); } - /// Meta-schema v3 lists every system function the parser knows, each where - /// its keyword takes it, so a name it admits always parses and none the - /// parser knows is refused by it: the string transformations for - /// `generatedFrom`, the hashes for a lookup key. + /// Meta-schema v3 lists every system function the parser knew at protocol + /// version 14, each where its keyword takes it, so a name it admits always + /// parses: the string transformations for `generatedFrom`, the hashes for a + /// lookup key. The token functions came with protocol version 15, whose + /// registration is checked natively (`native_schema`), and the meta-schema, + /// frozen with protocol version 14, keeps refusing them. #[test] fn should_list_every_function_in_the_meta_schema() { let meta_schema: serde_json::Value = serde_json::from_str(include_str!( @@ -216,23 +255,20 @@ mod tests { .map(|name| name.as_str().expect("a name").to_string()) .collect() }; - let names = |string_transformation: bool| -> Vec { + let names = |keep: fn(&SystemFunction) -> bool| -> Vec { SystemFunction::ALL .iter() - .filter(|function| { - matches!(function, SystemFunction::StringTransformation(_)) - == string_transformation - }) + .filter(|function| keep(function)) .map(|function| function.as_str().to_string()) .collect() }; assert_eq!( listed("/$defs/documentSchema/properties/generatedFrom/properties/function/enum"), - names(true) + names(|function| matches!(function, SystemFunction::StringTransformation(_))) ); assert_eq!( listed("/$defs/findByFunction/properties/function/enum"), - names(false) + names(|function| matches!(function, SystemFunction::Hash(_))) ); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/tokens.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/tokens.rs new file mode 100644 index 00000000000..927bb5d0355 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/tokens.rs @@ -0,0 +1,30 @@ +//! `sys.tokens`: system functions of a token, named by its id, that read the +//! token's configuration (protocol version 15). +//! +//! Unlike the string transformations, these read state: the token's +//! configuration lives in the contract that defines it, and its conventions can +//! change after a document is written. So a property one of them generates is +//! generated when the document is created, and again only when a replace changes +//! the token it names; otherwise the document keeps the value it was created +//! with. The platform reads the value while it builds the write, and clients, +//! which hold no state, leave the property out for the platform to fill. + +/// A `sys.tokens` function. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenFunction { + /// `sys.tokens.decimals`: the token's decimals, from its conventions, as an + /// integer from 0 to 255. + Decimals, +} + +impl TokenFunction { + /// Every token function, in wire-name order. + pub const ALL: [TokenFunction; 1] = [TokenFunction::Decimals]; + + /// The wire name, the value of `generatedFrom.function`. + pub const fn as_str(&self) -> &'static str { + match self { + TokenFunction::Decimals => "sys.tokens.decimals", + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 295f4a26cb5..e7b17533de8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -51,6 +51,7 @@ pub mod reference_where; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; pub use generated_from::{ GeneratedFrom, GenerationParam, HashFunction, StringTransformation, SystemFunction, + TokenFunction, }; pub use list_element_reference::ListElementReference; pub use lookup_preimage::{ diff --git a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs index 02a41bd71e0..5831f6365c2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs +++ b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs @@ -12,6 +12,7 @@ use crate::data_contract::document_type::{ DocumentPropertyType, EncryptedFor, EncryptedForRecipient, EncryptionScheme, GeneratedFrom, GenerationParam, HashFunction, IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, StringPropertySizes, StringTransformation, SystemFunction, + TokenFunction, }; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -144,6 +145,13 @@ pub(super) struct StoredGeneratedFrom { pub(super) enum StoredSystemFunction { StringTransformation(StoredStringTransformation), Hash(HashFunction), + Token(StoredTokenFunction), +} + +/// [`TokenFunction`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub(super) enum StoredTokenFunction { + Decimals, } /// [`StringTransformation`]. @@ -527,6 +535,9 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { }) } SystemFunction::Hash(hash) => StoredSystemFunction::Hash(*hash), + SystemFunction::Token(token) => StoredSystemFunction::Token(match token { + TokenFunction::Decimals => StoredTokenFunction::Decimals, + }), }, params: params .iter() @@ -556,6 +567,9 @@ impl From for GeneratedFrom { }) } StoredSystemFunction::Hash(hash) => SystemFunction::Hash(hash), + StoredSystemFunction::Token(token) => SystemFunction::Token(match token { + StoredTokenFunction::Decimals => TokenFunction::Decimals, + }), }, params: params.into_iter().map(GenerationParam::Property).collect(), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs index 57c77833125..12d1eac9bae 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs @@ -832,6 +832,8 @@ mod generated_from_tests { let action_data = |platform_version: &PlatformVersion| { let (result, _) = DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( + &fixture.platform.drive, + None, replace_transition, fixture.identity.id(), &stored, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_token.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_token.rs new file mode 100644 index 00000000000..bd481b3c607 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_token.rs @@ -0,0 +1,538 @@ +//! End-to-end coverage for `generatedFrom` with `sys.tokens.decimals` (protocol version 15): +//! an integer property the platform fills with the decimals of the token an identifier +//! property names, read from the token's contract when the document is created and kept +//! after, unless a replace names another token. A client leaves the property out; one that +//! sends a value other than the token's is refused, and so is a token that does not exist. + +use super::*; + +mod generated_from_token_tests { + use super::*; + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Setters; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; + use dpp::platform_value::platform_value; + use dpp::prelude::Identifier; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::drive::Drive; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + use std::sync::Arc; + + /// A mutable `order` type: the `tokenId` it is for, the token's `decimals`, generated + /// from it, and a free `note`. + fn order_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "indices": [ + { + "name": "byToken", + "properties": [{ "tokenId": "asc" }, { "decimals": "asc" }] + } + ], + "properties": { + "tokenId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "generatedFrom": { + "function": "sys.tokens.decimals", + "params": ["tokenId"] + }, + "position": 1 + }, + "note": { "type": "string", "maxLength": 32, "position": 2 } + }, + "required": ["tokenId", "decimals"], + "additionalProperties": false + }) + } + + /// One identity holding an `order` contract, and two tokens of other contracts: one of + /// 6 decimals, one of 18. + struct OrderFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + token_contract: DataContract, + token_id: Identifier, + other_token_id: Identifier, + next_nonce: IdentityNonce, + } + + impl OrderFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + let (issuer, _, _) = setup_identity(&mut platform, 959, dash_to_credits!(0.5)); + let (other_issuer, _, _) = setup_identity(&mut platform, 960, dash_to_credits!(0.5)); + let with_decimals = |decimals: u8| { + move |configuration: &mut TokenConfiguration| { + configuration.conventions_mut().set_decimals(decimals) + } + }; + let (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + issuer.id(), + Some(with_decimals(6)), + None, + None, + None, + platform_version, + ); + let (_, other_token_id) = create_token_contract_with_owner_identity( + &mut platform, + other_issuer.id(), + Some(with_decimals(18)), + None, + None, + None, + platform_version, + ); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "order", + order_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the order document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + token_contract, + token_id, + other_token_id, + next_nonce: 1, + } + } + + fn order_data(token_id: Identifier, decimals: Option) -> BTreeMap { + let mut data = BTreeMap::from([( + "tokenId".to_string(), + Value::Identifier(token_id.to_buffer()), + )]); + if let Some(decimals) = decimals { + data.insert("decimals".to_string(), Value::U8(decimals)); + } + data + } + + /// `transition` with its document's data set to exactly `data`, signed again. + async fn send_as_given( + &self, + mut transition: StateTransition, + data: BTreeMap, + ) -> StateTransition { + { + let StateTransition::Batch(batch) = &mut transition else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionMutRef::Document(document_transition)) = + batch.first_transition_mut() + else { + panic!("expected a document transition"); + }; + *document_transition + .data_mut() + .expect("the document transition carries data") = data; + } + transition + .sign_external( + &self.key, + &self.signer, + Some(|_, _| Ok(SecurityLevel::HIGH)), + ) + .await + .expect("expected to sign the transition again"); + transition + } + + /// A create transition for an order holding exactly `data`. + async fn create_transition( + &mut self, + data: BTreeMap, + seed: u64, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let document_type = self + .contract + .document_type_for_name("order") + .expect("expected the order document type"); + let mut rng = StdRng::seed_from_u64(seed); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(document_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + *document.properties_mut() = data.clone(); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + self.send_as_given(transition, data).await + } + + /// A replace transition for `stored`, its revision bumped, holding exactly `data`. + async fn replace_transition( + &mut self, + stored: &Document, + data: BTreeMap, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let document_type = self + .contract + .document_type_for_name("order") + .expect("expected the order document type"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + document_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.send_as_given(transition, data).await + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Proves the committed state `transition` wrote and verifies the proof as a client + /// holding the order contract does: it holds no state to generate the decimals from. + fn verify_proof(&self, transition: &StateTransition) -> StateTransitionProofResult { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(self.contract.id(), self.contract.clone())]); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), + platform_version, + ) + .expect("expected the proof to verify"); + outcome.into_result() + } + + fn stored_orders(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from order", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + + /// Sets the decimals of the first token to `decimals`, as a `Conventions` update + /// of its contract would. + fn change_token_decimals(&mut self, decimals: u8) { + let platform_version = PlatformVersion::latest(); + let mut token_contract = self.token_contract.clone(); + token_contract + .token_configuration_mut(0) + .expect("expected the token configuration") + .conventions_mut() + .set_decimals(decimals); + token_contract.increment_version(); + self.platform + .drive + .apply_contract( + &token_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to update the token contract"); + self.token_contract = token_contract; + } + } + + fn decimals_of(document: &Document) -> Option<&Value> { + document.properties().get("decimals") + } + + fn assert_success(result: &StateTransitionExecutionResult) { + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{result:?}" + ); + } + + #[tokio::test] + async fn should_write_the_token_decimals_on_a_create_that_leaves_them_out() { + let mut fixture = OrderFixture::new(); + + let transition = fixture + .create_transition(OrderFixture::order_data(fixture.token_id, None), 1) + .await; + assert_success(&fixture.process(&transition)); + + let stored = fixture.stored_orders(); + assert_eq!(stored.len(), 1); + assert_eq!( + decimals_of(&stored[0]).and_then(|value| value.to_integer::().ok()), + Some(6) + ); + // A client verifying the proof takes the decimals from the proved document + assert_matches!( + fixture.verify_proof(&transition), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.values().flatten().all(|document| { + decimals_of(document).and_then(|value| value.to_integer::().ok()) + == Some(6) + }) + ); + } + + #[tokio::test] + async fn should_accept_the_token_decimals_sent_and_refuse_others() { + let mut fixture = OrderFixture::new(); + + let transition = fixture + .create_transition(OrderFixture::order_data(fixture.token_id, Some(6)), 2) + .await; + assert_success(&fixture.process(&transition)); + + let transition = fixture + .create_transition(OrderFixture::order_data(fixture.token_id, Some(18)), 3) + .await; + let result = fixture.process(&transition); + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e)) + if e.property() == "decimals" && e.function() == "sys.tokens.decimals" + ); + assert_eq!(fixture.stored_orders().len(), 1); + } + + #[tokio::test] + async fn should_refuse_a_token_that_does_not_exist() { + let mut fixture = OrderFixture::new(); + + let unknown = Identifier::new([77; 32]); + let transition = fixture + .create_transition(OrderFixture::order_data(unknown, None), 4) + .await; + let result = fixture.process(&transition); + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)) + if e.entity_id() == &unknown && e.path() == "tokenId" + ); + assert!(fixture.stored_orders().is_empty()); + } + + /// The token's decimals change after the order is created: a replace that keeps the + /// token keeps the decimals the order was created with, a new order takes the new ones, + /// and a replace naming another token takes that token's. + #[tokio::test] + async fn should_keep_the_decimals_until_a_replace_names_another_token() { + let mut fixture = OrderFixture::new(); + + let transition = fixture + .create_transition(OrderFixture::order_data(fixture.token_id, None), 5) + .await; + assert_success(&fixture.process(&transition)); + fixture.change_token_decimals(2); + + let stored = fixture.stored_orders().remove(0); + let mut data = OrderFixture::order_data(fixture.token_id, None); + data.insert("note".to_string(), Value::Text("kept".to_string())); + let transition = fixture.replace_transition(&stored, data).await; + assert_success(&fixture.process(&transition)); + let replaced = fixture.stored_orders().remove(0); + assert_eq!(replaced.get("note"), Some(&Value::Text("kept".to_string()))); + assert_eq!( + decimals_of(&replaced).and_then(|value| value.to_integer::().ok()), + Some(6) + ); + assert_matches!( + fixture.verify_proof(&transition), + StateTransitionProofResult::VerifiedDocuments(_) + ); + + // The token's current decimals are refused while the order keeps the token + let transition = fixture + .replace_transition( + &replaced, + OrderFixture::order_data(fixture.token_id, Some(2)), + ) + .await; + assert_matches!( + fixture.process(&transition), + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(_)), + .. + } + ); + + // A new order takes the new decimals + let transition = fixture + .create_transition(OrderFixture::order_data(fixture.token_id, None), 6) + .await; + assert_success(&fixture.process(&transition)); + assert!(fixture.stored_orders().iter().any(|order| { + decimals_of(order).and_then(|value| value.to_integer::().ok()) == Some(2) + })); + + // A replace naming another token takes that token's decimals + let replaced = fixture + .stored_orders() + .into_iter() + .find(|order| order.id() == stored.id()) + .expect("expected the first order"); + let transition = fixture + .replace_transition( + &replaced, + OrderFixture::order_data(fixture.other_token_id, None), + ) + .await; + assert_success(&fixture.process(&transition)); + let moved = fixture + .stored_orders() + .into_iter() + .find(|order| order.id() == stored.id()) + .expect("expected the first order"); + assert_eq!( + decimals_of(&moved).and_then(|value| value.to_integer::().ok()), + Some(18) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 55c0e815075..92838c45e0c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -15,6 +15,7 @@ mod duplicate_keys; mod encrypted_for; mod gas_sponsorship; mod generated_from; +mod generated_from_token; mod id_reuse; mod immutable; mod immutable_conditions; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 2e21c2fa3eb..7b791a77400 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -1041,6 +1041,8 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { let (mut document_replace_action, fee_result) = DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( + drive, + transaction, document_replace_transition, owner_id, original_document, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index 882d706dede..61c63bd730e 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -19,6 +19,7 @@ use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::document_transition::state_generated_properties::generate_state_read_properties; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; @@ -87,6 +88,37 @@ impl DocumentCreateTransitionActionV0 { let mut data = data.clone(); document_type.fill_generated_properties(&mut data, platform_version)?; + // Added in place at protocol version 15, inert before it, where no type declares a + // `generatedFrom` function that reads state: every such property is written from + // state here, or checked when sent, before the contest resolution and every later + // check read the data. A refusal bumps the nonce, and the write pays for the reads + // either way. + let (refusal, mut fee_result) = generate_state_read_properties( + drive, + document_type, + base.data_contract_fetch_info_ref(), + &mut data, + None, + block_info, + transaction, + platform_version, + )?; + if let Some(error) = refusal { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( + &base, + owner_id, + user_fee_increase, + ); + return Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + vec![error], + ), + fee_result, + )); + } + let document_type_indexes = document_type.indexes(); let prefunded_voting_balances_by_vote_poll = prefunded_voting_balance @@ -124,8 +156,6 @@ impl DocumentCreateTransitionActionV0 { }) .transpose()?; - let mut fee_result = FeeResult::default(); - let (current_store_contest_info, should_store_contest_info) = if let Some((contested_document_resource_vote_poll, _)) = &prefunded_voting_balances_by_vote_poll @@ -138,9 +168,9 @@ impl DocumentCreateTransitionActionV0 { platform_version, )?; - fee_result = fetch_fee_result.ok_or(Error::Drive( + fee_result.checked_add_assign(fetch_fee_result.ok_or(Error::Drive( DriveError::CorruptedCodeExecution("expected fee result"), - ))?; + ))?)?; let should_store_contest_info = if maybe_current_store_contest_info.is_none() { // We are starting a new contest Some(ContestedDocumentVotePollStoredInfo::new( diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs index 2e1258df23d..86f9ba49810 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs @@ -8,7 +8,9 @@ use dpp::ProtocolError; use dpp::state_transition::batch_transition::batched_transition::DocumentReplaceTransition; use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; use crate::error::Error; +use grovedb::TransactionArg; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; @@ -16,6 +18,8 @@ impl DocumentReplaceTransitionAction { /// try from borrowed #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_document_replace_transition( + drive: &Drive, + transaction: TransactionArg, document_replace_transition: &DocumentReplaceTransition, owner_id: Identifier, original_document: &Document, @@ -33,6 +37,8 @@ impl DocumentReplaceTransitionAction { match document_replace_transition { DocumentReplaceTransition::V0(v0) => { DocumentReplaceTransitionActionV0::try_from_borrowed_document_replace_transition( + drive, + transaction, v0, owner_id, original_document, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index 9431db2257e..83990724b9e 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -11,17 +11,22 @@ use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransitionV0; use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; use crate::error::Error; +use grovedb::TransactionArg; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::v0::DocumentReplaceTransitionActionV0; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use crate::state_transition_action::batch::batched_transition::document_transition::state_generated_properties::generate_state_read_properties; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; impl DocumentReplaceTransitionActionV0 { /// try from borrowed #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_document_replace_transition( + drive: &Drive, + transaction: TransactionArg, document_replace_transition: &DocumentReplaceTransitionV0, owner_id: Identifier, original_document: &Document, @@ -83,6 +88,37 @@ impl DocumentReplaceTransitionActionV0 { base.document_type()? .fill_generated_properties(&mut data, platform_version)?; + // Added in place at protocol version 15, inert before it, where no type declares a + // `generatedFrom` function that reads state: every such property keeps the stored + // value while the replace keeps the token it names, and is read from state again when + // it names another, before the changed fields below and every later check read the + // data. A refusal bumps the nonce, and the write pays for the reads either way. + let (refusal, fee_result) = generate_state_read_properties( + drive, + base.document_type()?, + base.data_contract_fetch_info_ref(), + &mut data, + Some(original_document.properties()), + block_info, + transaction, + platform_version, + )?; + if let Some(error) = refusal { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( + &base, + owner_id, + user_fee_increase, + ); + return Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + vec![error], + ), + fee_result, + )); + } + let updated_at = if base.document_type_field_is_required(property_names::UPDATED_AT)? { Some(block_info.time_ms) } else { @@ -212,7 +248,7 @@ impl DocumentReplaceTransitionActionV0 { .into(), )) .into(), - FeeResult::default(), + fee_result, )) } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs index f601d5fcf72..a9340b394ca 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs @@ -17,6 +17,7 @@ mod document_transition_action_type; pub mod document_update_price_transition_action; /// property_constraint_reads pub mod property_constraint_reads; +mod state_generated_properties; pub use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionType; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/state_generated_properties.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/state_generated_properties.rs new file mode 100644 index 00000000000..a531304471c --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/state_generated_properties.rs @@ -0,0 +1,113 @@ +//! The `generatedFrom` properties whose function reads state (`sys.tokens`, protocol version +//! 15), written or checked while a document create or replace action is built. + +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::data_contract::document_type::{DocumentTypeRef, TokenFunction}; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::Value; +use dpp::tokens::calculate_token_id; +use dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Writes or checks every property of `data` that `document_type` generates with a function +/// reading state (`DocumentTypeBasicMethods::generate_state_read_properties`), reading each +/// token it names: its contract info, then its contract, unless that is the document's own +/// (`contract`). Returns the refusal of the first property that does not pass, and the fee of +/// every read, which the write pays whether it passes or not. `stored` is the replaced +/// document's properties, `None` on a create. +/// +/// Added in place at protocol version 15 and inert before it, where no type declares such a +/// function: then nothing is read and the fee is zero. +#[allow(clippy::too_many_arguments)] +pub(super) fn generate_state_read_properties( + drive: &Drive, + document_type: DocumentTypeRef, + contract: &Arc, + data: &mut BTreeMap, + stored: Option<&BTreeMap>, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result<(Option, FeeResult), Error> { + if !document_type + .generated_from_fields() + .iter() + .any(|(_, generated_from)| generated_from.function.reads_state()) + { + return Ok((None, FeeResult::default())); + } + let mut fee = FeeResult::default(); + let refusal = document_type.generate_state_read_properties( + data, + stored, + |function, token_id| -> Result, Error> { + let (token_contract_info, info_fee) = drive.fetch_token_contract_info_with_costs( + token_id.to_buffer(), + block_info, + true, + transaction, + platform_version, + )?; + fee.checked_add_assign(info_fee)?; + let Some(token_contract_info) = token_contract_info else { + return Ok(None); + }; + let token_contract_id = token_contract_info.contract_id(); + let position = token_contract_info.token_contract_position(); + if calculate_token_id(token_contract_id.as_bytes(), position) != token_id.to_buffer() { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "contract info does not match token {token_id}" + )))); + } + let token_contract = if token_contract_id == contract.contract.id() { + contract.clone() + } else { + let (contract_fee, token_contract) = drive.get_contract_with_fetch_info_and_fee( + token_contract_id.to_buffer(), + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + fee.checked_add_assign(contract_fee.ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "a contract fetched with an epoch has a fee", + ), + ))?)?; + token_contract.ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "contract {token_contract_id} of token {token_id} is missing" + ))) + })? + }; + let token_configuration = + token_contract + .contract + .tokens() + .get(&position) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "contract {token_contract_id} has no token at position {position}" + ))) + })?; + Ok(Some(match function { + TokenFunction::Decimals => Value::U8(token_configuration.conventions().decimals()), + })) + }, + )?; + Ok((refusal, fee)) +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index b003c6a6fb4..8c58e3d5465 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -12,6 +12,7 @@ use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::document::{Document, DocumentV0Getters}; @@ -507,7 +508,7 @@ impl Drive { let (root_hash, documents) = match document_transition { DocumentTransition::Create(create_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (create)", create_transition.base().id()))))?; - let expected_document = Document::try_from_create_transition( + let mut expected_document = Document::try_from_create_transition( create_transition, documents_batch_transition.owner_id(), block_info, @@ -515,6 +516,12 @@ impl Drive { &document_type, platform_version, )?; + // A property generated from state (protocol version 15) is + // taken as proved: the verifier holds no state to generate it + document_type.adopt_state_generated_properties( + expected_document.properties_mut(), + document.properties(), + ); let transient_fields = document_type .transient_fields() @@ -533,7 +540,7 @@ impl Drive { } DocumentTransition::Replace(replace_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (replace)", replace_transition.base().id()))))?; - let expected_document = Document::try_from_replace_transition( + let mut expected_document = Document::try_from_replace_transition( replace_transition, documents_batch_transition.owner_id(), document.created_at(), //we can trust the created at (as we don't care) @@ -547,6 +554,12 @@ impl Drive { &document_type, platform_version, )?; + // A property generated from state (protocol version 15) is + // taken as proved: the verifier holds no state to generate it + document_type.adopt_state_generated_properties( + expected_document.properties_mut(), + document.properties(), + ); let transient_fields = document_type .transient_fields() diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs index e11941cd6e6..17c0de8c080 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -17,7 +17,10 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// natively instead of against the document meta-schema through the `jsonschema` crate, and /// every new document type must have a validation schema; /// - `validation_schema`, `Some(0)` rather than `None`: builds that typed validation schema -/// and the native validator. +/// and the native validator; +/// - `apply_generated_from`, `Some(1)` rather than `Some(0)`: a `generatedFrom` property may +/// also be generated with `sys.tokens.decimals`, the decimals of the token an identifier +/// property names, on an integer property. /// /// v6 remains unchanged for `PROTOCOL_VERSION_14`, whose parse refuses a byte array key and /// whose schemas and documents the `jsonschema` crate checks. @@ -36,6 +39,7 @@ pub const CONTRACT_VERSIONS_V7: DPPContractVersions = DPPContractVersions { parse_property_constraints: Some(1), // changed: countOf and sumOf match byte array keys // changed: documents are checked against the typed validation schema this builds validation_schema: Some(0), + apply_generated_from: Some(1), // changed: admits sys.tokens.decimals ..CONTRACT_VERSIONS_V6.document_type_versions.schema }, ..CONTRACT_VERSIONS_V6.document_type_versions diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 262457bcaf6..3b7131b74cb 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -160,6 +160,24 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// has its documents checked natively (note 6). The parser is generation 3 /// at both versions, and loading a stored contract runs no schema check. /// Protocol v14 and earlier keep `check_document_type_schema` 0. +/// +/// 8. **A token's decimals, generated**: `CONTRACT_VERSIONS_V7` selects +/// `apply_generated_from` 1, which admits the `generatedFrom` function +/// `sys.tokens.decimals` on an integer property admitting 0 to 255: the +/// decimals of the token an identifier property names, read from the +/// token's contract. It is the first function that reads state, so its +/// value is generated when a document is created and kept after, unless a +/// replace names another token: the document create and replace +/// transformers write a left-out value, or check a sent one, from state or +/// from the stored document (`generate_state_read_properties`), billing the +/// reads, and refuse a token that does not exist +/// (`ReferencedEntityNotFoundError`) or a sent value other than the one +/// generated or kept (`DocumentPropertyNotGeneratedError`). Registration +/// refuses it on an indexOnly type and in a contested index; clients leave +/// the property out, and a proof verifier takes it from the proved +/// document. Those call sites were extended in place and are inert before +/// this version, where no type declares such a function. Protocol v14 and +/// earlier keep `apply_generated_from` 0. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, @@ -178,7 +196,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V4, - contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7) + contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7); apply_generated_from 1 admits sys.tokens.decimals (note 8) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V2, From f2821cf94b93d11531989a27916fd51698675f28 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 13:00:29 +0300 Subject: [PATCH 60/62] feat(platform)!: token market orders generate their decimals from the token ask and bid declare decimals generatedFrom sys.tokens.decimals of tokenId, so an order keeps the token's decimals from when it was placed. Co-Authored-By: Claude Opus 5.5 --- packages/token-market-contract/README.md | 2 +- .../v1/token-market-contract-documents.json | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/token-market-contract/README.md b/packages/token-market-contract/README.md index 1d699cff5a1..425fbc3fc50 100644 --- a/packages/token-market-contract/README.md +++ b/packages/token-market-contract/README.md @@ -24,7 +24,7 @@ Tokens a holder offers for sale at a price. | `price` | integer, required | The credits the seller asks per whole token (10 to the power of `decimals` base units) | | `amount` | integer, required | The base units listed when the order was placed | | `remaining` | integer, required | The base units still for sale; the order is deleted when it is filled entirely | -| `decimals` | integer, required | The token's decimals when the order was placed. Every fill prices the order at this scale, so a later change to the token's decimals does not change what an open order costs or pays | +| `decimals` | integer, required | The token's decimals when the order was placed, generated from `tokenId` (`generatedFrom` `sys.tokens.decimals`) and kept after. Every fill prices the order at this scale, so a later change to the token's decimals does not change what an open order costs or pays | ## `bid` diff --git a/packages/token-market-contract/schema/v1/token-market-contract-documents.json b/packages/token-market-contract/schema/v1/token-market-contract-documents.json index 470a05c0043..e1ce44a0402 100644 --- a/packages/token-market-contract/schema/v1/token-market-contract-documents.json +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -84,7 +84,13 @@ "type": "integer", "minimum": 0, "maximum": 255, - "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "description": "The token's decimals when the order was placed, generated from tokenId: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "generatedFrom": { + "function": "sys.tokens.decimals", + "params": [ + "tokenId" + ] + }, "position": 4 } }, @@ -190,7 +196,13 @@ "type": "integer", "minimum": 0, "maximum": 255, - "description": "The token's decimals when the order was placed: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "description": "The token's decimals when the order was placed, generated from tokenId: the price is per 10 to the power of this many base units for the life of the order, even if the token's decimals change later", + "generatedFrom": { + "function": "sys.tokens.decimals", + "params": [ + "tokenId" + ] + }, "position": 5 } }, From 59fba488f408564afdddac53ceee4bb5737d7c15 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 18:13:12 +0300 Subject: [PATCH 61/62] test(drive-abci): re-pin the shielded characterization roots The genesis state at the latest protocol version changed (the token market contract's orders drop minFill and generate their decimals), so the settled hashes that cover GroveDB roots move. Every validation hash (actions, errors, fees) is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../token/shielded/nullifier_batch_guard.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 52dcf099acd..d5363bb6ff2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "31a2711610cce7bfe3c867f742060b45e87d9ec7040406cb435a7fbc6dbfabb9" + "26d2841e0b7449553f62b7074ac0852710d00e59c8ca89b70f4fd9d352625d8d" ); assert_eq!( pool.platform @@ -2160,39 +2160,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "7e3cf25f4080c94ef028ede8d3bdc982d0055feaef0d9e75dcae11287146d425", - "4f426a32592b74ce2b330c6b74cc078cc96356ccf7c860055d7f66fa7c17c15a", + "691041eaebb403585dadda2af3c80a72979da112e824e5ef98b402ec9973f75f", ), "valid-payment" => ( "da69c2e10a0fe88df56655907ae5c3acd6700f13823358159f6240b1fb33c6d1", - "befd11331ae6dcdda2f227235f693af5be04baebdb1d92fa9c198e171d81de93", + "b54dda0c755964d628ec04d52f8ebdae9fad1f72f43148a45cdabc8c52bc7f9f", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "9406ec31654e1be46319e776553ff0e1bc5d3777baa5734beac9c44999be2cbe", + "10cdee54c2c246f74628211f27d3ac2c1937aec7232e4bbd5cc86f262061aacb", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "2adb48919121dc0633c71b38ebd950cb2e21e92f4bb78d39a9853dd0c106631d", + "f3811733d94ee1a330e8e4d507ddc3c560fe46dff602bbf7eb193153dcad36d1", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "30d235d51bf25d5989a42cb7a5e1c041b879290a102194b483dd09a34839a3d9", + "d34e49e0600bee998bf9ce03fe32546af60c9afc9a9565d29d01dd554dae8814", ), "open-mint-group" => ( "7f694d1f3507aaca0c5b5b1254eef3f8cd595379c23c32dea1d0a9c21d302088", - "2b7c116b03552673520ea15835c36762bde578bd247cd930968aeb601c7e4b27", + "06195ed43902e31cfe4cc30746a1f35139e6dd8b27c8dd2f3abeb7744d1c1eb0", ), "closing-mint-group" => ( "1901253314ac7c543ffa24ae22b91f6790365b66bfa9b477dce90bacd3c97e75", - "bcb9aa7d6c0114dbf8ca2ad0bc3c69e8207ff12ad984e8b83368a934f7ec59b1", + "7a90cbe8a8c01a4d89912bb5c1f047c44bd4eb92c117552e5a275786be91288f", ), "open-burn-group" => ( "eb4786ab90fb36ffc33d8167e199316929ef28cceb0dcff61e6612cd6fcc1d73", - "88c5b2024157cf6e1e4091c5d8c9735a55335afc0f9db246d6baefdc1bced268", + "2cc9d37a7d0413cb9fc2909eeecf0283050fa98f906ac9203884a9bea3a5b8de", ), "closing-burn-group" => ( "dbb52b37ff8c4b451cf36ee3a6a8b1d299d0f786f55cad6c700ef1b947bc1179", - "c0ef13aea8123a8d013bee70c6b08a125b4ede6ae53cf46abc3dfba895005c66", + "c2d76b22601721cfe1c0e968fa11d0e09d9f20faa7549d7fa95f12173b6a3de1", ), _ => unreachable!("enumerated characterization case"), }; From eb5ddbebe2aef913cbda17ab2e9a780b94b9326f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 28 Oct 2026 10:17:52 +0300 Subject: [PATCH 62/62] test(dpp): the system contract size cap test covers the token market contract Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/system_data_contracts.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index c13b13221e4..1196e11841a 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -184,6 +184,7 @@ mod tests { SystemDataContract::ModerationCharters => { contract_versions.moderation_charters == 0 } + SystemDataContract::TokenMarket => contract_versions.token_market == 0, SystemDataContract::Withdrawals | SystemDataContract::MasternodeRewards | SystemDataContract::DPNS