From 9de6dacfc02e2541981c9c0effe4a71114d386e5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 8 Oct 2026 16:17:29 +0200 Subject: [PATCH 001/121] 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 002/121] 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 003/121] 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 004/121] 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 005/121] 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 006/121] 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 007/121] 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 008/121] 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 009/121] 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 010/121] 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 011/121] 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 012/121] 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 013/121] 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 014/121] 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 015/121] 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 016/121] 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 017/121] 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 018/121] 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 019/121] 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 020/121] 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 021/121] 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 022/121] 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 023/121] 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 024/121] 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 025/121] 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 026/121] 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 027/121] 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 028/121] 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 029/121] 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 030/121] 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 031/121] 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 032/121] 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 033/121] 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 b7bd591fbf1f1172562ceb5254d601e523aaf08f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 22:36:44 +0300 Subject: [PATCH 034/121] feat(platform)!: token marketplace listings (PV15) Holders of a tradeable token can list it for sale (TokenPlaceAsk), buy part or all of a listing (TokenFillAsk) and withdraw an open order (TokenCancelOrder). Listed tokens wait in escrow; a fill moves them to the buyer and the buyer's credits to the seller in the same transition, paying the change in the listing's rounded-up running total. Each kind is refused unpaid before protocol version 15 by the batch's is_allowed gate, driven by the exhaustive TokenTransition::is_marketplace_order. New basic errors 10462-10463 and state errors 40750-40757, token events OrderPlaced/OrderFilled/OrderCancelled, proofs for each kind, wasm-dpp2 bindings, the wallet signing summary, the book and v15 note 7. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 76 ++ .../token_configuration/mod.rs | 23 +- .../src/errors/consensus/basic/basic_error.rs | 25 + .../invalid_token_order_fill_range_error.rs | 58 ++ .../token/invalid_token_order_price_error.rs | 56 ++ .../src/errors/consensus/basic/token/mod.rs | 4 + packages/rs-dpp/src/errors/consensus/codes.rs | 10 + .../src/errors/consensus/state/state_error.rs | 94 +++ .../src/errors/consensus/state/token/mod.rs | 16 + .../state/token/token_not_tradeable_error.rs | 51 ++ .../state/token/token_order_expired_error.rs | 69 ++ .../token/token_order_fill_amount_error.rs | 85 ++ .../token/token_order_not_found_error.rs | 57 ++ .../token/token_order_not_owned_error.rs | 64 ++ .../token/token_order_price_limit_error.rs | 68 ++ .../token/token_order_self_fill_error.rs | 58 ++ .../token_order_value_too_large_error.rs | 66 ++ .../src/serialization/json/safe_fields.rs | 2 + packages/rs-dpp/src/shielded/sighash.rs | 5 +- packages/rs-dpp/src/state_transition/mod.rs | 7 + .../batched_transition/mod.rs | 3 + .../token_cancel_order_transition/mod.rs | 38 + .../token_cancel_order_transition/v0/mod.rs | 42 + .../v0/v0_methods.rs | 47 ++ .../v0_methods.rs | 52 ++ .../validate_structure/mod.rs | 36 + .../validate_structure/v0/mod.rs | 14 + .../token_fill_ask_transition/mod.rs | 38 + .../token_fill_ask_transition/v0/mod.rs | 48 ++ .../v0/v0_methods.rs | 72 ++ .../token_fill_ask_transition/v0_methods.rs | 77 ++ .../validate_structure/mod.rs | 36 + .../validate_structure/v0/mod.rs | 24 + .../token_place_ask_transition/mod.rs | 38 + .../token_place_ask_transition/v0/mod.rs | 51 ++ .../v0/v0_methods.rs | 72 ++ .../token_place_ask_transition/v0_methods.rs | 77 ++ .../validate_structure/mod.rs | 36 + .../validate_structure/v0/mod.rs | 43 + .../batched_transition/token_transition.rs | 95 ++- .../token_transition_action_type.rs | 18 + .../document/batch_transition/methods/mod.rs | 180 ++++ .../batch_transition/methods/v1/mod.rs | 62 ++ .../document/batch_transition/mod.rs | 10 +- .../batch_transition/v1/v1_methods.rs | 194 +++++ .../validate_basic_structure/v0/mod.rs | 15 + packages/rs-dpp/src/tokens/mod.rs | 2 + packages/rs-dpp/src/tokens/token_event.rs | 119 ++- packages/rs-dpp/src/tokens/token_order.rs | 386 +++++++++ .../rs-dpp/src/tokens/token_order_side.rs | 87 ++ .../processor/traits/is_allowed.rs | 83 +- .../processor/traits/shielded_proof.rs | 5 +- .../batch/action_validation/token/mod.rs | 4 + .../mod.rs | 59 ++ .../state_v0/mod.rs | 46 + .../token_fill_ask_transition_action/mod.rs | 59 ++ .../state_v0/mod.rs | 108 +++ .../token/token_order_common/mod.rs | 69 ++ .../token_place_ask_transition_action/mod.rs | 59 ++ .../state_v0/mod.rs | 135 +++ .../v0/accepted_token_pool_nullifiers.rs | 5 +- .../state_transitions/batch/state/v0/mod.rs | 33 + .../batch/tests/token/marketplace/mod.rs | 796 ++++++++++++++++++ .../batch/tests/token/mod.rs | 1 + .../batch/transformer/v0/mod.rs | 36 + .../group_queries/group_actions/v0/mod.rs | 5 +- .../drive/tokens/order_escrow/fetch_order.rs | 85 ++ .../src/drive/tokens/order_escrow/mod.rs | 1 + .../batch/token/mod.rs | 3 + .../token/token_cancel_order_transition.rs | 85 ++ .../batch/token/token_fill_ask_transition.rs | 113 +++ .../batch/token/token_place_ask_transition.rs | 83 ++ .../batch/token/token_transition.rs | 28 + .../token_transition/mod.rs | 32 + .../mod.rs | 45 + .../transformer.rs | 53 ++ .../v0/mod.rs | 66 ++ .../v0/transformer.rs | 149 ++++ .../token_fill_ask_transition_action/mod.rs | 66 ++ .../transformer.rs | 53 ++ .../v0/mod.rs | 102 +++ .../v0/transformer.rs | 194 +++++ .../token_place_ask_transition_action/mod.rs | 79 ++ .../transformer.rs | 53 ++ .../v0/mod.rs | 114 +++ .../v0/transformer.rs | 129 +++ .../token_transition_action_type.rs | 3 + .../state_transition_action/batch/v0/mod.rs | 8 + .../src/util/batch/drive_op_batch/mod.rs | 28 +- .../src/util/batch/drive_op_batch/token.rs | 86 ++ .../token_transition.rs | 43 + .../v0/mod.rs | 118 +++ .../drive_abci_validation_versions/mod.rs | 8 + .../drive_abci_validation_versions/v1.rs | 6 + .../drive_abci_validation_versions/v10.rs | 6 + .../drive_abci_validation_versions/v2.rs | 6 + .../drive_abci_validation_versions/v3.rs | 6 + .../drive_abci_validation_versions/v4.rs | 6 + .../drive_abci_validation_versions/v5.rs | 6 + .../drive_abci_validation_versions/v6.rs | 6 + .../drive_abci_validation_versions/v7.rs | 6 + .../drive_abci_validation_versions/v8.rs | 6 + .../drive_abci_validation_versions/v9.rs | 6 + .../mod.rs | 3 + .../v1.rs | 3 + .../v2.rs | 3 + .../v3.rs | 3 + .../v4.rs | 3 + .../rs-platform-version/src/version/v15.rs | 9 + .../network/state_transition_summary.rs | 29 +- .../batch_transition/token_transition/mod.rs | 12 + .../src/errors/consensus/consensus_error.rs | 36 + packages/wasm-dpp2/src/group/token_event.rs | 17 + .../batch/token_transition.rs | 45 +- .../batch/token_transitions/mod.rs | 3 + .../token_transitions/token_cancel_order.rs | 130 +++ .../batch/token_transitions/token_fill_ask.rs | 148 ++++ .../token_transitions/token_place_ask.rs | 138 +++ 118 files changed, 6841 insertions(+), 37 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/tokens/token_order.rs create mode 100644 packages/rs-dpp/src/tokens/token_order_side.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs create mode 100644 packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs create mode 100644 packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs create mode 100644 packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 4741bbbaddd..8d3b999956a 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -81,3 +81,79 @@ What an order holds is kept outside the contract: - 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. + +## Listings + +Three token transitions of a batch work with listings. Each is refused before protocol version +15, unpaid, by the batch's `is_allowed` gate (`StateTransitionNotActiveError` 10603, as +`TokenPlaceAsk`, `TokenFillAsk` or `TokenCancelOrder`). + +| Transition | Fields | Effect | +|---|---|---| +| `TokenPlaceAsk` | `amount`, `price`, `minFill`, optional `expiresAt` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | +| `TokenFillAsk` | `orderId`, `minAmount`, `maxAmount`, `maxTotalPrice` | Moves part or all of the listing from escrow to the buyer, and its cost from the buyer's credits to the seller's | +| `TokenCancelOrder` | `orderId`, `side` | Returns what an order holds to its owner and deletes the order | + +A listing's id follows from the token, the seller and the transition's identity contract nonce +(`TokenOrderSide::order_id`), so a client knows it before the listing is placed. + +### Price and partial fills + +`price` is in credits per whole token, 10 to the power of the token's decimals base units. A fill +takes as much as it may: everything open, up to `maxAmount`. A listing never keeps less than +`minFill` open, so a fill that would leave less takes less, leaving exactly `minFill`; a fill +that does not take everything open takes at least `minFill`. A `minFill` equal to `amount` makes +the listing all-or-nothing. The fill is refused when the amount it would take is under +`minAmount`, or when it costs more than `maxTotalPrice`. + +Each fill pays the change in the listing's running total rounded up, so splitting a purchase +never changes what it costs in all: + +```text +2 decimals, 333 credits per token (3.33 per base unit), a listing of 3 base units +one fill of 3: 10 credits +three fills of 1: 4 + 3 + 3 = 10 credits +``` + +The rounding favours the seller and is never more than one credit per fill. + +### Rules + +| Rule | Refused with | +|---|---| +| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463) | +| A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | +| The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | +| The order is open, for this token | `TokenOrderNotFoundError` (40751) | +| A listing's `expiresAt` is after the block, and a fill comes before it | `TokenOrderExpiredError` (40752) | +| The fill range meets the listing | `TokenOrderFillAmountError` (40753) | +| The fill costs at most `maxTotalPrice` | `TokenOrderPriceLimitError` (40754) | +| Only the order's owner cancels it | `TokenOrderNotOwnedError` (40755) | +| A seller does not fill their own listing | `TokenOrderSelfFillError` (40756) | +| A listing is worth at most `i64::MAX` credits | `TokenOrderValueTooLargeError` (40757) | +| The seller holds `amount` | `IdentityDoesNotHaveEnoughTokenBalanceError` | +| The token is not paused (to place or fill) | `TokenIsPausedError` | +| The seller's account is not frozen (to place or fill), nor the buyer's unless the token allows transfers to frozen balances | `IdentityTokenAccountFrozenError` | + +The state rules are paid: a refused transition bumps the signer's nonce and charges the work that +ran. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the +token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are +checked with the batch's fees, which count a fill's cost like a direct purchase. + +### Storage and proofs + +The seller pays the storage of the `ask` and gets it back when the listing closes, by a fill that +takes everything left or by a cancel. A partial fill rewrites the `ask` without changing its size, +so the buyer pays no storage for it beyond their own token balance. Selling a listing charges the +seller nothing. + +The proof a node returns for each transition: + +| Transition | Proves | +|---|---| +| `TokenPlaceAsk` | The `ask` document, holding the listing's terms | +| `TokenFillAsk` | The buyer's token balance | +| `TokenCancelOrder` | That the order's document is gone | + +Offers (`bid`) and their fills come with the next transitions of the marketplace. Until then no +offer can exist, and a `TokenCancelOrder` for one finds none. 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..8e396c31ba6 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 @@ -69,12 +69,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 @@ -162,12 +166,7 @@ impl TokenConfiguration { token_contract_position: TokenContractPosition, platform_version: &PlatformVersion, ) -> SimpleConsensusValidationResult { - if !self - .as_cow_v0() - .marketplace_rules - .trade_mode() - .is_tradeable() - { + if !self.is_tradeable() { return SimpleConsensusValidationResult::new(); } if platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { 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 da94a676c73..bfad2b32f12 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -111,6 +111,9 @@ use crate::consensus::basic::state_transition::{ TransitionOverMaxInputsError, TransitionOverMaxOutputsError, WithdrawalBalanceMismatchError, WithdrawalBelowMinAmountError, }; +use crate::consensus::basic::token::{ + InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, +}; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, }; @@ -869,6 +872,14 @@ pub enum BasicError { // A non-transferable token cannot be tradeable (protocol version 15). #[error(transparent)] NonTransferableTokenTradeableError(NonTransferableTokenTradeableError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderPriceError(InvalidTokenOrderPriceError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderFillRangeError(InvalidTokenOrderFillRangeError), } impl From for ConsensusError { @@ -1085,6 +1096,20 @@ mod tests { )), 206 ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderPriceError( + InvalidTokenOrderPriceError::new(0, 1) + )), + 207 + ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderFillRangeError( + InvalidTokenOrderFillRangeError::new(0, 1) + )), + 208 + ); } /// 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/token/invalid_token_order_fill_range_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs new file mode 100644 index 00000000000..3fabba4e0fd --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs @@ -0,0 +1,58 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A token order's fill range is empty: a listing or an offer whose minimum fill is 0 or above +/// its amount, or a fill whose minimum is 0 or above its maximum. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token order fill range is invalid: the minimum {min} must be at least 1 and at most {max}" +)] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderFillRangeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + min: TokenAmount, + max: TokenAmount, +} + +impl InvalidTokenOrderFillRangeError { + pub fn new(min: TokenAmount, max: TokenAmount) -> Self { + Self { min, max } + } + + pub fn min(&self) -> TokenAmount { + self.min + } + + pub fn max(&self) -> TokenAmount { + self.max + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderFillRangeError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderFillRangeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs new file mode 100644 index 00000000000..5785e7a8222 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs @@ -0,0 +1,56 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A token order's price is the credits per whole token: at least 1 credit, and at most what +/// an order's signed 8-byte fields hold. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order price {price} must be between 1 and {max_price} credits per whole token")] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderPriceError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + price: Credits, + max_price: Credits, +} + +impl InvalidTokenOrderPriceError { + pub fn new(price: Credits, max_price: Credits) -> Self { + Self { price, max_price } + } + + pub fn price(&self) -> Credits { + self.price + } + + pub fn max_price(&self) -> Credits { + self.max_price + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderPriceError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderPriceError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index 249807c3af7..ad5c0a660bc 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -35,3 +35,7 @@ pub use missing_default_localization::*; pub use token_note_only_allowed_on_proposer_error::*; pub use token_pricing_schedule_empty_error::*; pub use token_transfer_to_ourselves_error::*; +mod invalid_token_order_price_error; +pub use invalid_token_order_price_error::*; +mod invalid_token_order_fill_range_error; +pub use invalid_token_order_fill_range_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index b50505b3fe5..63c09f650f3 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -126,6 +126,8 @@ impl ErrorWithCode for BasicError { Self::NonTransferableTokenShieldedPoolError(_) => 10279, Self::NonTransferableTokenPaymentMustBurnError(_) => 10280, Self::NonTransferableTokenTradeableError(_) => 10281, + Self::InvalidTokenOrderPriceError(_) => 10462, + Self::InvalidTokenOrderFillRangeError(_) => 10463, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, @@ -388,6 +390,14 @@ impl ErrorWithCode for StateError { Self::DocumentDeleteConstraintViolatedError(_) => 40147, Self::ReferencedDocumentRuleReadInvalidError(_) => 40148, Self::ReferencedDocumentPrecededError(_) => 40149, + Self::TokenNotTradeableError(_) => 40750, + Self::TokenOrderNotFoundError(_) => 40751, + Self::TokenOrderExpiredError(_) => 40752, + Self::TokenOrderFillAmountError(_) => 40753, + Self::TokenOrderPriceLimitError(_) => 40754, + Self::TokenOrderNotOwnedError(_) => 40755, + Self::TokenOrderSelfFillError(_) => 40756, + Self::TokenOrderValueTooLargeError(_) => 40757, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 7b60b11c177..f3789edb6b3 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -5,6 +5,7 @@ use platform_serialization_derive::{ }; use thiserror::Error; +use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderValueTooLargeError}; use crate::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use crate::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use crate::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -745,6 +746,38 @@ pub enum StateError { // `chooseFirst` groups by (protocol version 14). #[error(transparent)] ReferencedDocumentPrecededError(ReferencedDocumentPrecededError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenNotTradeableError(TokenNotTradeableError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderNotFoundError(TokenOrderNotFoundError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderExpiredError(TokenOrderExpiredError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderFillAmountError(TokenOrderFillAmountError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderPriceLimitError(TokenOrderPriceLimitError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderNotOwnedError(TokenOrderNotOwnedError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderSelfFillError(TokenOrderSelfFillError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderValueTooLargeError(TokenOrderValueTooLargeError), } impl From for ConsensusError { @@ -771,6 +804,7 @@ mod tests { LookupKeySource, }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; + use crate::tokens::token_order_side::TokenOrderSide; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use crate::voting::vote_polls::VotePoll; @@ -1674,5 +1708,65 @@ mod tests { )), 175 ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenNotTradeableError( + TokenNotTradeableError::new(Identifier::from([1; 32])) + )), + 176 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderNotFoundError( + TokenOrderNotFoundError::new(Identifier::from([1; 32]), TokenOrderSide::Ask) + )), + 177 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderExpiredError( + TokenOrderExpiredError::new(Identifier::from([1; 32]), 1, 2) + )), + 178 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderFillAmountError( + TokenOrderFillAmountError::new(Identifier::from([1; 32]), 1, 2, 3, 4) + )), + 179 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderPriceLimitError( + TokenOrderPriceLimitError::new(Identifier::from([1; 32]), 2, 1) + )), + 180 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderNotOwnedError( + TokenOrderNotOwnedError::new( + Identifier::from([1; 32]), + Identifier::from([1; 32]), + Identifier::from([1; 32]) + ) + )), + 181 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderSelfFillError( + TokenOrderSelfFillError::new(Identifier::from([1; 32]), Identifier::from([1; 32])) + )), + 182 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderValueTooLargeError( + TokenOrderValueTooLargeError::new(u64::MAX, u64::MAX, 0) + )), + 183 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index 71a94a6d7fb..bf45ef60251 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -67,3 +67,19 @@ pub use token_shielded_payment_not_required_error::*; pub use token_shielded_pool_not_enabled_error::*; pub use token_transfer_recipient_identity_not_exist_error::*; pub use unauthorized_token_action_error::*; +mod token_not_tradeable_error; +pub use token_not_tradeable_error::*; +mod token_order_not_found_error; +pub use token_order_not_found_error::*; +mod token_order_expired_error; +pub use token_order_expired_error::*; +mod token_order_fill_amount_error; +pub use token_order_fill_amount_error::*; +mod token_order_price_limit_error; +pub use token_order_price_limit_error::*; +mod token_order_not_owned_error; +pub use token_order_not_owned_error::*; +mod token_order_self_fill_error; +pub use token_order_self_fill_error::*; +mod token_order_value_too_large_error; +pub use token_order_value_too_large_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs new file mode 100644 index 00000000000..84ebbe213cd --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs @@ -0,0 +1,51 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// The token's marketplace trade mode is not `Tradeable`, so it cannot be listed or offered for, +/// and its open orders cannot be filled (they can still be cancelled). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token {token_id} is not tradeable")] +#[platform_serialize(unversioned)] +pub struct TokenNotTradeableError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, +} + +impl TokenNotTradeableError { + pub fn new(token_id: Identifier) -> Self { + Self { token_id } + } + + pub fn token_id(&self) -> Identifier { + self.token_id + } +} + +impl From for ConsensusError { + fn from(err: TokenNotTradeableError) -> Self { + Self::StateError(StateError::TokenNotTradeableError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs new file mode 100644 index 00000000000..5bee9b10e00 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs @@ -0,0 +1,69 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::prelude::TimestampMillis; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A token order's expiry has passed, or a new order expires before it could be filled. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order {order_id} expires at {expires_at}, at or before the block time {block_time}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderExpiredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + expires_at: TimestampMillis, + block_time: TimestampMillis, +} + +impl TokenOrderExpiredError { + pub fn new( + order_id: Identifier, + expires_at: TimestampMillis, + block_time: TimestampMillis, + ) -> Self { + Self { + order_id, + expires_at, + block_time, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn expires_at(&self) -> TimestampMillis { + self.expires_at + } + + pub fn block_time(&self) -> TimestampMillis { + self.block_time + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderExpiredError) -> Self { + Self::StateError(StateError::TokenOrderExpiredError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs new file mode 100644 index 00000000000..a01b8e7ce61 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs @@ -0,0 +1,85 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// No amount between the fill's minimum and maximum can be taken from the order: less is open than +/// the fill's minimum, or the amount would be under the order's minimum fill without taking +/// everything open, or would leave less than the minimum fill open. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A fill of token order {order_id} between {min_amount} and {max_amount} cannot be taken: {remaining} is open, and a fill takes at least {min_fill} and leaves none or at least {min_fill}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderFillAmountError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + remaining: TokenAmount, + min_fill: TokenAmount, +} + +impl TokenOrderFillAmountError { + pub fn new( + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + remaining: TokenAmount, + min_fill: TokenAmount, + ) -> Self { + Self { + order_id, + min_amount, + max_amount, + remaining, + min_fill, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn min_amount(&self) -> TokenAmount { + self.min_amount + } + + pub fn max_amount(&self) -> TokenAmount { + self.max_amount + } + + pub fn remaining(&self) -> TokenAmount { + self.remaining + } + + pub fn min_fill(&self) -> TokenAmount { + self.min_fill + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderFillAmountError) -> Self { + Self::StateError(StateError::TokenOrderFillAmountError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs new file mode 100644 index 00000000000..2da706c31a2 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// No open order of the side has this id for the token: it never existed, was filled entirely or +/// was cancelled. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order {order_id} ({side}) was not found")] +#[platform_serialize(unversioned)] +pub struct TokenOrderNotFoundError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + side: TokenOrderSide, +} + +impl TokenOrderNotFoundError { + pub fn new(order_id: Identifier, side: TokenOrderSide) -> Self { + Self { order_id, side } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn side(&self) -> TokenOrderSide { + self.side + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderNotFoundError) -> Self { + Self::StateError(StateError::TokenOrderNotFoundError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs new file mode 100644 index 00000000000..ebb25eae6e6 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs @@ -0,0 +1,64 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// Only an order's maker can cancel it. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order {order_id} belongs to {owner_id}, not {sender_id}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderNotOwnedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + owner_id: Identifier, + sender_id: Identifier, +} + +impl TokenOrderNotOwnedError { + pub fn new(order_id: Identifier, owner_id: Identifier, sender_id: Identifier) -> Self { + Self { + order_id, + owner_id, + sender_id, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn owner_id(&self) -> Identifier { + self.owner_id + } + + pub fn sender_id(&self) -> Identifier { + self.sender_id + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderNotOwnedError) -> Self { + Self::StateError(StateError::TokenOrderNotOwnedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs new file mode 100644 index 00000000000..84bcce5da1d --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs @@ -0,0 +1,68 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// The credits a fill moves are past the limit the filler set: more than its maximum for a +/// purchase, less than its minimum for a sale. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The fill of token order {order_id} moves {total} credits, past the filler's limit of {limit}" +)] +#[platform_serialize(unversioned)] +pub struct TokenOrderPriceLimitError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + total: Credits, + limit: Credits, +} + +impl TokenOrderPriceLimitError { + pub fn new(order_id: Identifier, total: Credits, limit: Credits) -> Self { + Self { + order_id, + total, + limit, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn total(&self) -> Credits { + self.total + } + + pub fn limit(&self) -> Credits { + self.limit + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderPriceLimitError) -> Self { + Self::StateError(StateError::TokenOrderPriceLimitError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs new file mode 100644 index 00000000000..73dab0f585e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs @@ -0,0 +1,58 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// An identity cannot fill its own order: it cancels it instead. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Identity {identity_id} cannot fill its own token order {order_id}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderSelfFillError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + identity_id: Identifier, +} + +impl TokenOrderSelfFillError { + pub fn new(order_id: Identifier, identity_id: Identifier) -> Self { + Self { + order_id, + identity_id, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn identity_id(&self) -> Identifier { + self.identity_id + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderSelfFillError) -> Self { + Self::StateError(StateError::TokenOrderSelfFillError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs new file mode 100644 index 00000000000..9dcd4fa6e8a --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs @@ -0,0 +1,66 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// What a token order is worth in credits, its amount at its price, does not fit in what Platform +/// can hold. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A token order of {amount} base units at {price} credits per whole token ({decimals} decimals) is worth more credits than Platform can hold")] +#[platform_serialize(unversioned)] +pub struct TokenOrderValueTooLargeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + amount: TokenAmount, + price: Credits, + decimals: u8, +} + +impl TokenOrderValueTooLargeError { + pub fn new(amount: TokenAmount, price: Credits, decimals: u8) -> Self { + Self { + amount, + price, + decimals, + } + } + + pub fn amount(&self) -> TokenAmount { + self.amount + } + + pub fn price(&self) -> Credits { + self.price + } + + pub fn decimals(&self) -> u8 { + self.decimals + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderValueTooLargeError) -> Self { + Self::StateError(StateError::TokenOrderValueTooLargeError(err)) + } +} diff --git a/packages/rs-dpp/src/serialization/json/safe_fields.rs b/packages/rs-dpp/src/serialization/json/safe_fields.rs index cacaa59f0d1..5dc9d6b0d58 100644 --- a/packages/rs-dpp/src/serialization/json/safe_fields.rs +++ b/packages/rs-dpp/src/serialization/json/safe_fields.rs @@ -11,6 +11,7 @@ use crate::state_transition::batch_transition::batched_transition::{ use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use crate::tokens::emergency_action::TokenEmergencyAction; +use crate::tokens::token_order_side::TokenOrderSide; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::tokens::token_payment_info::TokenPaymentInfo; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -149,6 +150,7 @@ impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} impl JsonSafeFields for crate::tokens::token_event::TokenEvent {} // `TokenEmergencyAction` is a unit-variant enum (Pause / Resume). impl JsonSafeFields for TokenEmergencyAction {} +impl JsonSafeFields for TokenOrderSide {} // `TokenDistributionType` is a unit-variant enum. impl JsonSafeFields for TokenDistributionType {} // `TokenPricingSchedule` has tuple variants holding `Credits` (u64) and diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 4bf46574a32..eef43b1c47f 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -744,7 +744,10 @@ fn outputs_only_bundle_tag_v0(action_type: TokenTransitionActionType) -> Result< | TokenTransitionActionType::SetPriceForDirectPurchase | TokenTransitionActionType::BurnFromPool | TokenTransitionActionType::Unshield - | TokenTransitionActionType::ShieldedTransfer) => { + | TokenTransitionActionType::ShieldedTransfer + | TokenTransitionActionType::PlaceAsk + | TokenTransitionActionType::FillAsk + | TokenTransitionActionType::CancelOrder) => { Err(ProtocolError::InvalidStateTransitionType(format!( "{other} is not an outputs-only token pool transition and has no bundle tag" ))) diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index fcdef43b5ef..beac62f4dae 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1351,6 +1351,13 @@ impl StateTransition { BatchedTransitionRef::Token(TokenTransition::DirectPurchaseToPool(_)) => { "TokenDirectPurchaseToPool" } + BatchedTransitionRef::Token(TokenTransition::PlaceAsk(_)) => { + "TokenPlaceAsk" + } + BatchedTransitionRef::Token(TokenTransition::FillAsk(_)) => "TokenFillAsk", + BatchedTransitionRef::Token(TokenTransition::CancelOrder(_)) => { + "TokenCancelOrder" + } }; document_transition_types.push(document_transition_name); } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs index 3f193f9b005..73e3574c3a3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs @@ -18,6 +18,7 @@ mod resolvers; pub mod token_base_transition; pub mod token_burn_from_pool_transition; pub mod token_burn_transition; +pub mod token_cancel_order_transition; pub mod token_claim_to_pool_transition; pub mod token_claim_transition; pub mod token_config_update_transition; @@ -25,9 +26,11 @@ pub mod token_destroy_frozen_funds_transition; pub mod token_direct_purchase_to_pool_transition; pub mod token_direct_purchase_transition; pub mod token_emergency_action_transition; +pub mod token_fill_ask_transition; pub mod token_freeze_transition; pub mod token_mint_to_pool_transition; pub mod token_mint_transition; +pub mod token_place_ask_transition; pub mod token_set_price_for_direct_purchase_transition; pub mod token_shield_transition; pub mod token_shielded_transfer_transition; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs new file mode 100644 index 00000000000..ff68d98f212 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenCancelOrderTransitionV0; + +/// A versioned `TokenCancelOrderTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenCancelOrderTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenCancelOrderTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenCancelOrderTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenCancelOrderTransition {} + +impl Default for TokenCancelOrderTransition { + fn default() -> Self { + TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs new file mode 100644 index 00000000000..40807885dc3 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs @@ -0,0 +1,42 @@ +pub mod v0_methods; + +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::tokens::token_order_side::TokenOrderSide; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenCancelOrderTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Cancels the signer's own open order `order_id`: what it still holds in escrow goes back to +/// the signer and the order is deleted. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenCancelOrderTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The order to cancel. + pub order_id: Identifier, + /// Whether the order is a listing or an offer. + pub side: TokenOrderSide, +} + +impl fmt::Display for TokenCancelOrderTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token CancelOrder, base: {}, order: {}, side: {}", + self.base, self.order_id, self.side + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..9ef197eb454 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs @@ -0,0 +1,47 @@ +use crate::tokens::token_order_side::TokenOrderSide; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; + +impl TokenBaseTransitionAccessors for TokenCancelOrderTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenCancelOrderTransitionV0Methods: TokenBaseTransitionAccessors { + fn order_id(&self) -> Identifier; + + fn set_order_id(&mut self, order_id: Identifier); + + fn side(&self) -> TokenOrderSide; + + fn set_side(&mut self, side: TokenOrderSide); +} + +impl TokenCancelOrderTransitionV0Methods for TokenCancelOrderTransitionV0 { + fn order_id(&self) -> Identifier { + self.order_id + } + + fn set_order_id(&mut self, order_id: Identifier) { + self.order_id = order_id; + } + + fn side(&self) -> TokenOrderSide { + self.side + } + + fn set_side(&mut self, side: TokenOrderSide) { + self.side = side; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs new file mode 100644 index 00000000000..f07ca598bdd --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs @@ -0,0 +1,52 @@ +use crate::tokens::token_order_side::TokenOrderSide; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use crate::state_transition::batch_transition::TokenCancelOrderTransition; + +impl TokenBaseTransitionAccessors for TokenCancelOrderTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenCancelOrderTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenCancelOrderTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenCancelOrderTransitionV0Methods for TokenCancelOrderTransition { + fn order_id(&self) -> Identifier { + match self { + TokenCancelOrderTransition::V0(v0) => v0.order_id(), + } + } + + fn set_order_id(&mut self, order_id: Identifier) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.set_order_id(order_id), + } + } + + fn side(&self) -> TokenOrderSide { + match self { + TokenCancelOrderTransition::V0(v0) => v0.side(), + } + } + + fn set_side(&mut self, side: TokenOrderSide) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.set_side(side), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..d679a528a00 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs @@ -0,0 +1,36 @@ +use crate::state_transition::batch_transition::token_cancel_order_transition::validate_structure::v0::TokenCancelOrderTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenCancelOrderTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenCancelOrderTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionStructureValidation for TokenCancelOrderTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_cancel_order_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenCancelOrderTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..b2793143d0e --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs @@ -0,0 +1,14 @@ +use crate::state_transition::batch_transition::TokenCancelOrderTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait TokenCancelOrderTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenCancelOrderTransitionStructureValidationV0 for TokenCancelOrderTransition { + /// A cancel names an order and its side, which state validation finds and checks. + fn validate_structure_v0(&self) -> Result { + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs new file mode 100644 index 00000000000..479d2fccd27 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenFillAskTransitionV0; + +/// A versioned `TokenFillAskTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenFillAskTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenFillAskTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenFillAskTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenFillAskTransition {} + +impl Default for TokenFillAskTransition { + fn default() -> Self { + TokenFillAskTransition::V0(TokenFillAskTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs new file mode 100644 index 00000000000..909c488f8da --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs @@ -0,0 +1,48 @@ +pub mod v0_methods; + +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenFillAskTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Buys from the open listing `order_id`: everything still open, up to `max_amount`, and at +/// least `min_amount`. The buyer pays the listing's price for what the fill takes, refused when +/// that is above `max_total_price`; the tokens move from escrow to the buyer. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenFillAskTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The listing to buy from. + pub order_id: Identifier, + /// The least amount, in base units, the buyer accepts; less still open refuses the fill. + pub min_amount: TokenAmount, + /// The most amount, in base units, the buyer takes. + pub max_amount: TokenAmount, + /// The most credits the buyer pays for the fill. + pub max_total_price: Credits, +} + +impl fmt::Display for TokenFillAskTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token FillAsk, base: {}, order: {}, min amount: {}, max amount: {}, max total price: {}", + self.base, self.order_id, self.min_amount, self.max_amount, self.max_total_price + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..28331e0c030 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs @@ -0,0 +1,72 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; + +impl TokenBaseTransitionAccessors for TokenFillAskTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenFillAskTransitionV0Methods: TokenBaseTransitionAccessors { + fn order_id(&self) -> Identifier; + + fn set_order_id(&mut self, order_id: Identifier); + + fn min_amount(&self) -> TokenAmount; + + fn set_min_amount(&mut self, min_amount: TokenAmount); + + fn max_amount(&self) -> TokenAmount; + + fn set_max_amount(&mut self, max_amount: TokenAmount); + + fn max_total_price(&self) -> Credits; + + fn set_max_total_price(&mut self, max_total_price: Credits); +} + +impl TokenFillAskTransitionV0Methods for TokenFillAskTransitionV0 { + fn order_id(&self) -> Identifier { + self.order_id + } + + fn set_order_id(&mut self, order_id: Identifier) { + self.order_id = order_id; + } + + fn min_amount(&self) -> TokenAmount { + self.min_amount + } + + fn set_min_amount(&mut self, min_amount: TokenAmount) { + self.min_amount = min_amount; + } + + fn max_amount(&self) -> TokenAmount { + self.max_amount + } + + fn set_max_amount(&mut self, max_amount: TokenAmount) { + self.max_amount = max_amount; + } + + fn max_total_price(&self) -> Credits { + self.max_total_price + } + + fn set_max_total_price(&mut self, max_total_price: Credits) { + self.max_total_price = max_total_price; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs new file mode 100644 index 00000000000..1a209067729 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs @@ -0,0 +1,77 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenFillAskTransition; + +impl TokenBaseTransitionAccessors for TokenFillAskTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenFillAskTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenFillAskTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenFillAskTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenFillAskTransitionV0Methods for TokenFillAskTransition { + fn order_id(&self) -> Identifier { + match self { + TokenFillAskTransition::V0(v0) => v0.order_id(), + } + } + + fn set_order_id(&mut self, order_id: Identifier) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_order_id(order_id), + } + } + + fn min_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransition::V0(v0) => v0.min_amount(), + } + } + + fn set_min_amount(&mut self, min_amount: TokenAmount) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_min_amount(min_amount), + } + } + + fn max_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransition::V0(v0) => v0.max_amount(), + } + } + + fn set_max_amount(&mut self, max_amount: TokenAmount) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_max_amount(max_amount), + } + } + + fn max_total_price(&self) -> Credits { + match self { + TokenFillAskTransition::V0(v0) => v0.max_total_price(), + } + } + + fn set_max_total_price(&mut self, max_total_price: Credits) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_max_total_price(max_total_price), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..47f26f8de7a --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs @@ -0,0 +1,36 @@ +use crate::state_transition::batch_transition::token_fill_ask_transition::validate_structure::v0::TokenFillAskTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenFillAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenFillAskTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionStructureValidation for TokenFillAskTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenFillAskTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..8d321672984 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs @@ -0,0 +1,24 @@ +use crate::consensus::basic::token::InvalidTokenOrderFillRangeError; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenFillAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait TokenFillAskTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenFillAskTransitionStructureValidationV0 for TokenFillAskTransition { + /// The fill's range is not empty: 1 <= `min_amount` <= `max_amount` <= `i64::MAX`. + fn validate_structure_v0(&self) -> Result { + if self.min_amount() == 0 + || self.min_amount() > self.max_amount() + || self.max_amount() > i64::MAX as u64 + { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(self.min_amount(), self.max_amount()).into(), + )); + } + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs new file mode 100644 index 00000000000..c705e18ccbb --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenPlaceAskTransitionV0; + +/// A versioned `TokenPlaceAskTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenPlaceAskTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenPlaceAskTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenPlaceAskTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenPlaceAskTransition {} + +impl Default for TokenPlaceAskTransition { + fn default() -> Self { + TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs new file mode 100644 index 00000000000..f3127cd1afb --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs @@ -0,0 +1,51 @@ +pub mod v0_methods; + +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::prelude::TimestampMillis; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use bincode::{Decode, DecodeUntrusted, Encode}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenPlaceAskTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Lists `amount` of a tradeable token for sale at `price` credits per whole token. The amount +/// moves from the seller's balance into escrow, where it stays until the listing is filled or +/// cancelled. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenPlaceAskTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The token amount listed, in base units. + pub amount: TokenAmount, + /// The credits asked per whole token (10 to the power of the token's decimals base units). + pub price: Credits, + /// 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. Equal to `amount` for a listing + /// that is sold all at once or not at all. + pub min_fill: TokenAmount, + /// The block time from which the listing can no longer be filled. It can still be + /// cancelled. + pub expires_at: Option, +} + +impl fmt::Display for TokenPlaceAskTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token PlaceAsk, base: {}, amount: {}, price: {}, min fill: {}, expires at: {:?}", + self.base, self.amount, self.price, self.min_fill, self.expires_at + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..3b3a91b4519 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs @@ -0,0 +1,72 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::prelude::TimestampMillis; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; + +impl TokenBaseTransitionAccessors for TokenPlaceAskTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenPlaceAskTransitionV0Methods: TokenBaseTransitionAccessors { + fn amount(&self) -> TokenAmount; + + fn set_amount(&mut self, amount: TokenAmount); + + fn price(&self) -> Credits; + + fn set_price(&mut self, price: Credits); + + fn min_fill(&self) -> TokenAmount; + + fn set_min_fill(&mut self, min_fill: TokenAmount); + + fn expires_at(&self) -> Option; + + fn set_expires_at(&mut self, expires_at: Option); +} + +impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { + fn amount(&self) -> TokenAmount { + self.amount + } + + fn set_amount(&mut self, amount: TokenAmount) { + self.amount = amount; + } + + fn price(&self) -> Credits { + self.price + } + + fn set_price(&mut self, price: Credits) { + self.price = price; + } + + fn min_fill(&self) -> TokenAmount { + self.min_fill + } + + fn set_min_fill(&mut self, min_fill: TokenAmount) { + self.min_fill = min_fill; + } + + fn expires_at(&self) -> Option { + self.expires_at + } + + fn set_expires_at(&mut self, expires_at: Option) { + self.expires_at = expires_at; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs new file mode 100644 index 00000000000..b60063205fa --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs @@ -0,0 +1,77 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::prelude::TimestampMillis; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; + +impl TokenBaseTransitionAccessors for TokenPlaceAskTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenPlaceAskTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenPlaceAskTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransition { + fn amount(&self) -> TokenAmount { + match self { + TokenPlaceAskTransition::V0(v0) => v0.amount(), + } + } + + fn set_amount(&mut self, amount: TokenAmount) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_amount(amount), + } + } + + fn price(&self) -> Credits { + match self { + TokenPlaceAskTransition::V0(v0) => v0.price(), + } + } + + fn set_price(&mut self, price: Credits) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_price(price), + } + } + + fn min_fill(&self) -> TokenAmount { + match self { + TokenPlaceAskTransition::V0(v0) => v0.min_fill(), + } + } + + fn set_min_fill(&mut self, min_fill: TokenAmount) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_min_fill(min_fill), + } + } + + fn expires_at(&self) -> Option { + match self { + TokenPlaceAskTransition::V0(v0) => v0.expires_at(), + } + } + + fn set_expires_at(&mut self, expires_at: Option) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_expires_at(expires_at), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..c7f5726545c --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -0,0 +1,36 @@ +use crate::state_transition::batch_transition::token_place_ask_transition::validate_structure::v0::TokenPlaceAskTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenPlaceAskTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionStructureValidation for TokenPlaceAskTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenPlaceAskTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..d893880ee16 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -0,0 +1,43 @@ +use crate::consensus::basic::token::{ + InvalidTokenAmountError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, +}; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +/// The largest amount and price an order may carry: both are held as signed 8-byte values (an +/// order's fields and its escrow entry), and the order's value is computed from them in u128. +const MAX_ORDER_VALUE: u64 = i64::MAX as u64; + +pub(super) trait TokenPlaceAskTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { + /// The amount and the price are between 1 and `i64::MAX`, and the minimum fill between 1 and + /// the amount. Whether the whole order's value fits is checked in state validation, which + /// knows the token's decimals. + fn validate_structure_v0(&self) -> Result { + if self.amount() == 0 || self.amount() > MAX_ORDER_VALUE { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(MAX_ORDER_VALUE, self.amount()), + )), + )); + } + if self.price() == 0 || self.price() > MAX_ORDER_VALUE { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderPriceError::new(self.price(), MAX_ORDER_VALUE).into(), + )); + } + if self.min_fill() == 0 || self.min_fill() > self.amount() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(self.min_fill(), self.amount()).into(), + )); + } + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs index 6e31dd1f946..bf6d5ebe960 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs @@ -21,7 +21,8 @@ use crate::prelude::IdentityNonce; use crate::ProtocolError; use crate::state_transition::batch_transition::{DocumentCreateTransition, DocumentDeleteTransition, DocumentReplaceTransition, TokenBurnTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintTransition, TokenClaimTransition, TokenTransferTransition, TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, TokenShieldedTransferTransition, TokenUnshieldTransition, TokenMintToPoolTransition, TokenBurnFromPoolTransition, TokenClaimToPoolTransition, - TokenDirectPurchaseToPoolTransition, + TokenDirectPurchaseToPoolTransition, TokenPlaceAskTransition, TokenFillAskTransition, + TokenCancelOrderTransition, }; #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] use crate::serialization::JsonConvertible; @@ -55,6 +56,10 @@ use crate::state_transition::batch_transition::token_direct_purchase_to_pool_tra use crate::state_transition::batch_transition::token_unshield_transition::v0::v0_methods::TokenUnshieldTransitionV0Methods; use crate::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; use crate::tokens::token_event::TokenEvent; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; pub const TOKEN_HISTORY_ID_BYTES: [u8; 32] = [ 45, 67, 89, 21, 34, 216, 145, 78, 156, 243, 17, 58, 202, 190, 13, 92, 61, 40, 122, 201, 84, 99, @@ -126,6 +131,18 @@ pub enum TokenTransition { #[display("TokenDirectPurchaseToPoolTransition({})", "_0")] DirectPurchaseToPool(TokenDirectPurchaseToPoolTransition), + + /// Lists a tradeable token for sale (the token marketplace, protocol version 15). + #[display("TokenPlaceAskTransition({})", "_0")] + PlaceAsk(TokenPlaceAskTransition), + + /// Buys from an open listing (the token marketplace, protocol version 15). + #[display("TokenFillAskTransition({})", "_0")] + FillAsk(TokenFillAskTransition), + + /// Cancels one of the signer's open orders (the token marketplace, protocol version 15). + #[display("TokenCancelOrderTransition({})", "_0")] + CancelOrder(TokenCancelOrderTransition), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -516,6 +533,11 @@ pub trait TokenTransitionV0Methods { /// has to declare which side it is on before it compiles. fn shielded_pool_actions(&self) -> Option<&[SerializedAction]>; + /// Whether this transition places, fills or cancels a token marketplace order, the kinds + /// protocol version 15 introduces. The match names all kinds, so a new kind has to declare + /// which side it is on before it compiles, and the batch's activation gate follows from it. + fn is_marketplace_order(&self) -> bool; + /// Historical document type name for the token history contract fn historical_document_type_name(&self) -> &str; /// Historical document type for the token history contract @@ -563,6 +585,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(t) => t.base(), TokenTransition::ClaimToPool(t) => t.base(), TokenTransition::DirectPurchaseToPool(t) => t.base(), + TokenTransition::PlaceAsk(t) => t.base(), + TokenTransition::FillAsk(t) => t.base(), + TokenTransition::CancelOrder(t) => t.base(), } } @@ -586,6 +611,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(t) => t.base_mut(), TokenTransition::ClaimToPool(t) => t.base_mut(), TokenTransition::DirectPurchaseToPool(t) => t.base_mut(), + TokenTransition::PlaceAsk(t) => t.base_mut(), + TokenTransition::FillAsk(t) => t.base_mut(), + TokenTransition::CancelOrder(t) => t.base_mut(), } } @@ -629,6 +657,10 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::ShieldedTransfer(_) | TokenTransition::ClaimToPool(_) | TokenTransition::DirectPurchaseToPool(_) => None, + // An order is an identity's own: it is never a group action. + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => None, } } @@ -651,7 +683,10 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::Unshield(_) | TokenTransition::ShieldedTransfer(_) | TokenTransition::ClaimToPool(_) - | TokenTransition::DirectPurchaseToPool(_) => false, + | TokenTransition::DirectPurchaseToPool(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => false, } } @@ -674,7 +709,36 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::EmergencyAction(_) | TokenTransition::ConfigUpdate(_) | TokenTransition::DirectPurchase(_) - | TokenTransition::SetPriceForDirectPurchase(_) => None, + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => None, + } + } + + fn is_marketplace_order(&self) -> bool { + match self { + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => true, + TokenTransition::Burn(_) + | TokenTransition::Mint(_) + | TokenTransition::Transfer(_) + | TokenTransition::Freeze(_) + | TokenTransition::Unfreeze(_) + | TokenTransition::DestroyFrozenFunds(_) + | TokenTransition::Claim(_) + | TokenTransition::EmergencyAction(_) + | TokenTransition::ConfigUpdate(_) + | TokenTransition::DirectPurchase(_) + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::Shield(_) + | TokenTransition::Unshield(_) + | TokenTransition::ShieldedTransfer(_) + | TokenTransition::MintToPool(_) + | TokenTransition::BurnFromPool(_) + | TokenTransition::ClaimToPool(_) + | TokenTransition::DirectPurchaseToPool(_) => false, } } @@ -719,6 +783,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(_) => "burnFromPool", TokenTransition::ClaimToPool(_) => "claimToPool", TokenTransition::DirectPurchaseToPool(_) => "directPurchaseToPool", + TokenTransition::PlaceAsk(_) => "orderPlaced", + TokenTransition::FillAsk(_) => "orderFilled", + TokenTransition::CancelOrder(_) => "orderCancelled", } } @@ -889,6 +956,28 @@ impl TokenTransitionV0Methods for TokenTransition { set_price_transition.public_note().cloned(), ) } + TokenTransition::PlaceAsk(place_ask) => TokenEvent::OrderPlaced( + TokenOrderSide::Ask.order_id( + place_ask.base().token_id(), + owner_id, + place_ask.base().identity_contract_nonce(), + ), + TokenOrderSide::Ask, + place_ask.amount(), + place_ask.price(), + ), + // we do not know the maker, nor how much the fill takes and costs, before the order + // is read + TokenTransition::FillAsk(fill_ask) => TokenEvent::OrderFilled( + fill_ask.order_id(), + TokenOrderSide::Ask, + Identifier::default(), + fill_ask.max_amount(), + fill_ask.max_total_price(), + ), + TokenTransition::CancelOrder(cancel_order) => { + TokenEvent::OrderCancelled(cancel_order.order_id(), cancel_order.side()) + } }) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs index cb797562b82..c6bb25fc6a0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs @@ -66,6 +66,15 @@ pub enum TokenTransitionActionType { /// Indicates a transfer inside the token's shielded pool. ShieldedTransfer, + + /// Indicates a token listed for sale on the token marketplace. + PlaceAsk, + + /// Indicates a purchase from an open listing on the token marketplace. + FillAsk, + + /// Indicates an open token marketplace order cancelled by its maker. + CancelOrder, } impl fmt::Display for TokenTransitionActionType { @@ -89,6 +98,9 @@ impl fmt::Display for TokenTransitionActionType { TokenTransitionActionType::BurnFromPool => "BurnFromPool", TokenTransitionActionType::ClaimToPool => "ClaimToPool", TokenTransitionActionType::DirectPurchaseToPool => "DirectPurchaseToPool", + TokenTransitionActionType::PlaceAsk => "PlaceAsk", + TokenTransitionActionType::FillAsk => "FillAsk", + TokenTransitionActionType::CancelOrder => "CancelOrder", }; write!(f, "{}", action_str) } @@ -123,6 +135,9 @@ impl TokenTransitionActionTypeGetter for TokenTransition { TokenTransition::DirectPurchaseToPool(_) => { TokenTransitionActionType::DirectPurchaseToPool } + TokenTransition::PlaceAsk(_) => TokenTransitionActionType::PlaceAsk, + TokenTransition::FillAsk(_) => TokenTransitionActionType::FillAsk, + TokenTransition::CancelOrder(_) => TokenTransitionActionType::CancelOrder, } } } @@ -160,6 +175,9 @@ impl TryFrom<&str> for TokenTransitionActionType { "direct_purchase_to_pool" | "directPurchaseToPool" => { Ok(TokenTransitionActionType::DirectPurchaseToPool) } + "place_ask" | "placeAsk" => Ok(TokenTransitionActionType::PlaceAsk), + "fill_ask" | "fillAsk" => Ok(TokenTransitionActionType::FillAsk), + "cancel_order" | "cancelOrder" => Ok(TokenTransitionActionType::CancelOrder), action_type => Err(ProtocolError::Generic(format!( "unknown token transition action type {action_type}" ))), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index c3c93e48f6c..9d3ad6d22c4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -20,6 +20,8 @@ use crate::identity::signer::Signer; use crate::identity::IdentityPublicKey; use crate::prelude::IdentityNonce; #[cfg(feature = "state-transition-signing")] +use crate::prelude::TimestampMillis; +#[cfg(feature = "state-transition-signing")] use crate::prelude::UserFeeIncrease; #[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; @@ -39,6 +41,8 @@ use crate::state_transition::StateTransitionSigningOptions; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] use crate::tokens::token_payment_info::TokenPaymentInfo; #[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -1570,6 +1574,182 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { }), } } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + expires_at: Option, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_place_ask_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + amount, + price, + min_fill, + expires_at, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_place_ask_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_fill_ask_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + order_id, + min_amount, + max_amount, + max_total_price, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_fill_ask_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_cancel_order_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + order_id, + side, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_cancel_order_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } } #[cfg(test)] diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs index dc0108a3b67..b2508b3ce2b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs @@ -13,6 +13,8 @@ use crate::identity::signer::Signer; #[cfg(feature = "state-transition-signing")] use crate::identity::IdentityPublicKey; #[cfg(feature = "state-transition-signing")] +use crate::prelude::TimestampMillis; +#[cfg(feature = "state-transition-signing")] use crate::prelude::{IdentityNonce, UserFeeIncrease}; #[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; @@ -24,6 +26,8 @@ use crate::state_transition::StateTransition; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; #[cfg(feature = "state-transition-signing")] use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; @@ -584,4 +588,62 @@ pub trait DocumentsBatchTransitionMethodsV1: DocumentsBatchTransitionAccessorsV0 platform_version: &PlatformVersion, options: Option, ) -> Result; + + /// Lists `amount` of a tradeable token for sale at `price` credits per whole token. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + expires_at: Option, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; + + /// Buys between `min_amount` and `max_amount` from the open listing `order_id`, paying at most `max_total_price`. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; + + /// Cancels the signer's own open order `order_id`. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs index 60958bdaa11..d3439c8f33f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs @@ -19,7 +19,8 @@ pub use self::batched_transition::{ document_replace_transition, document_replace_transition::DocumentReplaceTransition, token_base_transition, token_burn_from_pool_transition, token_burn_from_pool_transition::TokenBurnFromPoolTransition, token_burn_transition, - token_burn_transition::TokenBurnTransition, token_claim_to_pool_transition, + token_burn_transition::TokenBurnTransition, token_cancel_order_transition, + token_cancel_order_transition::TokenCancelOrderTransition, token_claim_to_pool_transition, token_claim_to_pool_transition::TokenClaimToPoolTransition, token_claim_transition, token_claim_transition::TokenClaimTransition, token_config_update_transition, token_config_update_transition::TokenConfigUpdateTransition, @@ -30,10 +31,13 @@ pub use self::batched_transition::{ token_direct_purchase_transition, token_direct_purchase_transition::TokenDirectPurchaseTransition, token_emergency_action_transition, - token_emergency_action_transition::TokenEmergencyActionTransition, token_freeze_transition, + token_emergency_action_transition::TokenEmergencyActionTransition, token_fill_ask_transition, + token_fill_ask_transition::TokenFillAskTransition, token_freeze_transition, token_freeze_transition::TokenFreezeTransition, token_mint_to_pool_transition, token_mint_to_pool_transition::TokenMintToPoolTransition, token_mint_transition, - token_mint_transition::TokenMintTransition, token_set_price_for_direct_purchase_transition, + token_mint_transition::TokenMintTransition, token_place_ask_transition, + token_place_ask_transition::TokenPlaceAskTransition, + token_set_price_for_direct_purchase_transition, token_set_price_for_direct_purchase_transition::TokenSetPriceForDirectPurchaseTransition, token_shield_transition, token_shield_transition::TokenShieldTransition, token_shielded_transfer_transition, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 7ac7a07d18f..9d6aee1d216 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -91,6 +91,18 @@ use crate::state_transition::GetDataContractSecurityLevelRequirementFn; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +use crate::prelude::TimestampMillis; +#[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::{TokenCancelOrderTransition, TokenFillAskTransition, TokenPlaceAskTransition}; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; +#[cfg(feature = "state-transition-signing")] use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; #[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -1482,4 +1494,186 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } Ok(state_transition) } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + expires_at: Option, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + amount, + price, + min_fill, + expires_at, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenFillAskTransition::V0(TokenFillAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + order_id, + min_amount, + max_amount, + max_total_price, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + order_id, + side, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs index de098140a44..1637e877cbd 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs @@ -46,6 +46,9 @@ use crate::state_transition::batch_transition::token_mint_to_pool_transition::va use crate::state_transition::batch_transition::token_burn_from_pool_transition::validate_structure::TokenBurnFromPoolTransitionStructureValidation; use crate::state_transition::batch_transition::token_claim_to_pool_transition::validate_structure::TokenClaimToPoolTransitionStructureValidation; use crate::state_transition::batch_transition::token_direct_purchase_to_pool_transition::validate_structure::TokenDirectPurchaseToPoolTransitionStructureValidation; +use crate::state_transition::batch_transition::token_place_ask_transition::validate_structure::TokenPlaceAskTransitionStructureValidation; +use crate::state_transition::batch_transition::token_fill_ask_transition::validate_structure::TokenFillAskTransitionStructureValidation; +use crate::state_transition::batch_transition::token_cancel_order_transition::validate_structure::TokenCancelOrderTransitionStructureValidation; use crate::state_transition::batch_transition::token_shielded_transfer_transition::validate_structure::TokenShieldedTransferTransitionStructureValidation; use crate::state_transition::batch_transition::token_unshield_transition::validate_structure::TokenUnshieldTransitionStructureValidation; use crate::state_transition::batch_transition::token_transfer_transition::validate_structure::TokenTransferTransitionStructureValidation; @@ -278,6 +281,18 @@ impl BatchTransition { TokenTransition::DirectPurchaseToPool(direct_purchase_to_pool_transition) => { direct_purchase_to_pool_transition.validate_structure(platform_version)? } + // The token marketplace arms, like the pool arms above, are reached only from + // the version that admits them: the batch's `is_allowed` gate refuses every order + // kind before protocol version 15. + TokenTransition::PlaceAsk(place_ask_transition) => { + place_ask_transition.validate_structure(platform_version)? + } + TokenTransition::FillAsk(fill_ask_transition) => { + fill_ask_transition.validate_structure(platform_version)? + } + TokenTransition::CancelOrder(cancel_order_transition) => { + cancel_order_transition.validate_structure(platform_version)? + } }; if !consensus_result.is_valid() { diff --git a/packages/rs-dpp/src/tokens/mod.rs b/packages/rs-dpp/src/tokens/mod.rs index b827b2c0dcf..9d1b2b57a73 100644 --- a/packages/rs-dpp/src/tokens/mod.rs +++ b/packages/rs-dpp/src/tokens/mod.rs @@ -13,6 +13,8 @@ pub mod info; pub mod status; pub mod token_amount_on_contract_token; pub mod token_event; +pub mod token_order; +pub mod token_order_side; pub mod token_payment_info; pub mod token_pricing_schedule; diff --git a/packages/rs-dpp/src/tokens/token_event.rs b/packages/rs-dpp/src/tokens/token_event.rs index 32e5bf396ec..e54d7c14bcb 100644 --- a/packages/rs-dpp/src/tokens/token_event.rs +++ b/packages/rs-dpp/src/tokens/token_event.rs @@ -34,6 +34,7 @@ pub type TokenEventPersonalEncryptedNote = Option<( )>; use crate::serialization::PlatformSerializableWithPlatformVersion; use crate::tokens::emergency_action::TokenEmergencyAction; +use crate::tokens::token_order_side::TokenOrderSide; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; use crate::tokens::SharedEncryptedNote; use crate::ProtocolError; @@ -208,6 +209,29 @@ pub enum TokenEvent { /// - `TokenAmount`: The amount of tokens purchased. /// - `Credits`: The number of credits paid. DirectPurchaseToPool(TokenAmount, Credits), + + /// Event representing a token marketplace order placed (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: Whether the order is a listing or an offer. + /// - `TokenAmount`: The amount listed or wanted, in base units. + /// - `Credits`: The price, in credits per whole token. + OrderPlaced(Identifier, TokenOrderSide, TokenAmount, Credits), + + /// Event representing a fill of a token marketplace order (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: The side of the order filled. + /// - `Identifier`: The order's maker. + /// - `TokenAmount`: The amount the fill took, in base units. + /// - `Credits`: The credits the fill moved between buyer and seller. + OrderFilled(Identifier, TokenOrderSide, Identifier, TokenAmount, Credits), + + /// Event representing a cancel of a token marketplace order (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: The side of the order cancelled. + OrderCancelled(Identifier, TokenOrderSide), } // Manual impl because TokenEvent is a flat enum with u64-alias tuple variants @@ -372,6 +396,32 @@ impl serde::Serialize for TokenEvent { m.serialize_entry("credits", &SafeU64(credits))?; m.end() } + TokenEvent::OrderPlaced(order_id, side, amount, price) => { + let mut m = serializer.serialize_map(Some(5))?; + m.serialize_entry("$type", "orderPlaced")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.serialize_entry("amount", &SafeU64(amount))?; + m.serialize_entry("price", &SafeU64(price))?; + m.end() + } + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => { + let mut m = serializer.serialize_map(Some(6))?; + m.serialize_entry("$type", "orderFilled")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.serialize_entry("maker", maker)?; + m.serialize_entry("amount", &SafeU64(amount))?; + m.serialize_entry("credits", &SafeU64(credits))?; + m.end() + } + TokenEvent::OrderCancelled(order_id, side) => { + let mut m = serializer.serialize_map(Some(3))?; + m.serialize_entry("$type", "orderCancelled")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.end() + } } } } @@ -423,6 +473,10 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { let mut action: Option = None; let mut configuration_change: Option = None; let mut pricing_schedule: Option = None; + let mut order_id: Option = None; + let mut side: Option = None; + let mut maker: Option = None; + let mut price: Option = None; while let Some(key) = map.next_key::()? { match key.as_str() { @@ -444,6 +498,10 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { "action" => action = Some(map.next_value()?), "configurationChange" => configuration_change = Some(map.next_value()?), "pricingSchedule" => pricing_schedule = map.next_value()?, + "orderId" => order_id = Some(map.next_value()?), + "side" => side = Some(map.next_value()?), + "maker" => maker = Some(map.next_value()?), + "price" => price = Some(map.next_value::()?.0), _ => { let _: IgnoredAny = map.next_value()?; } @@ -530,6 +588,23 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { amount.ok_or_else(|| A::Error::missing_field("amount"))?, credits.ok_or_else(|| A::Error::missing_field("credits"))?, )), + "orderPlaced" => Ok(TokenEvent::OrderPlaced( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + amount.ok_or_else(|| A::Error::missing_field("amount"))?, + price.ok_or_else(|| A::Error::missing_field("price"))?, + )), + "orderFilled" => Ok(TokenEvent::OrderFilled( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + maker.ok_or_else(|| A::Error::missing_field("maker"))?, + amount.ok_or_else(|| A::Error::missing_field("amount"))?, + credits.ok_or_else(|| A::Error::missing_field("credits"))?, + )), + "orderCancelled" => Ok(TokenEvent::OrderCancelled( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + )), other => Err(A::Error::unknown_variant( other, &[ @@ -551,6 +626,9 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { "burnFromPool", "claimToPool", "directPurchaseToPool", + "orderPlaced", + "orderFilled", + "orderCancelled", ], )), } @@ -779,6 +857,19 @@ impl fmt::Display for TokenEvent { "Direct purchase of {} into the token pool for {} credits", amount, credits ), + TokenEvent::OrderPlaced(order_id, side, amount, price) => write!( + f, + "Order {} placed: {} of {} at {} credits per whole token", + order_id, side, amount, price + ), + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => write!( + f, + "Order {} ({}) of {} filled: {} for {} credits", + order_id, side, maker, amount, credits + ), + TokenEvent::OrderCancelled(order_id, side) => { + write!(f, "Order {} ({}) cancelled", order_id, side) + } } } } @@ -811,6 +902,9 @@ impl TokenEvent { TokenEvent::BurnFromPool(..) => "burnFromPool", TokenEvent::ClaimToPool(..) => "claimToPool", TokenEvent::DirectPurchaseToPool(..) => "directPurchaseToPool", + TokenEvent::OrderPlaced(..) => "orderPlaced", + TokenEvent::OrderFilled(..) => "orderFilled", + TokenEvent::OrderCancelled(..) => "orderCancelled", } } @@ -839,7 +933,10 @@ impl TokenEvent { | TokenEvent::Unshield(_, _) | TokenEvent::ShieldedTransfer | TokenEvent::ClaimToPool(_) - | TokenEvent::DirectPurchaseToPool(_, _) => None, + | TokenEvent::DirectPurchaseToPool(_, _) + | TokenEvent::OrderPlaced(..) + | TokenEvent::OrderFilled(..) + | TokenEvent::OrderCancelled(..) => None, } } @@ -1068,6 +1165,26 @@ impl TokenEvent { ("tokenAmount".to_string(), amount.into()), ("purchaseCost".to_string(), total_cost.into()), ]), + TokenEvent::OrderPlaced(order_id, side, amount, price) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ("amount".to_string(), amount.into()), + ("price".to_string(), price.into()), + ]), + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ("makerId".to_string(), maker.into()), + ("amount".to_string(), amount.into()), + ("credits".to_string(), credits.into()), + ]), + TokenEvent::OrderCancelled(order_id, side) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ]), }; let document: Document = DocumentV0 { diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs new file mode 100644 index 00000000000..14f8c68a719 --- /dev/null +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -0,0 +1,386 @@ +use crate::balances::credits::TokenAmount; +use crate::block::block_info::BlockInfo; +use crate::document::{Document, DocumentV0, DocumentV0Getters}; +use crate::fee::Credits; +use crate::prelude::{Revision, TimestampMillis}; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::ProtocolError; +use platform_value::{Identifier, Value}; +use std::collections::BTreeMap; + +/// The token market contract's property names of an order. +pub mod property_names { + 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"; +} + +/// An open order of the token marketplace (protocol version 15), as the token market contract +/// stores it: a listing (`Ask`) of tokens for sale, or an offer (`Bid`) to buy them. +/// +/// The price is in credits per whole token, 10 to the power of the token's decimals base units. +/// A fill pays the change in the order's rounded running total, so splitting an order into +/// several fills never changes what it costs in all: a listing's buyers pay +/// `ceil((filled + q) * price / unit) - ceil(filled * price / unit)` for `q` base units, and an +/// offer's sellers receive the same difference rounded down. The rounding favours the order's +/// maker, and is never more than one credit per fill. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenOrder { + /// The order id: its document id in the token market contract and the key of its escrow. + pub id: Identifier, + /// A listing or an offer. + pub side: TokenOrderSide, + /// The identity that placed the order. + pub maker_id: Identifier, + /// The token the order is for. + pub token_id: Identifier, + /// The price, in credits per whole token. + pub price: Credits, + /// The amount, in base units, listed or wanted when the order was placed. + pub amount: TokenAmount, + /// The amount still open. + pub remaining: TokenAmount, + /// The smallest amount a fill takes unless it takes everything open; a fill never leaves + /// less than this open either. + pub min_fill: TokenAmount, + /// The block time from which the order can no longer be filled. + pub expires_at: Option, + /// For an offer, the credits still held in escrow; 0 for a listing, whose escrow holds + /// tokens (`remaining`). + pub escrow: Credits, +} + +fn unit(decimals: u8) -> Result { + 10u128.checked_pow(decimals as u32).ok_or_else(|| { + ProtocolError::Overflow("10 to the power of the token's decimals overflows u128") + }) +} + +fn total_at(filled: TokenAmount, price: Credits, unit: u128, round_up: bool) -> u128 { + // At most `i64::MAX * i64::MAX`, under `u128::MAX`. + let value = filled as u128 * price as u128; + if round_up { + value.div_ceil(unit) + } else { + value / unit + } +} + +impl TokenOrder { + /// The amount filled so far. + pub fn filled(&self) -> TokenAmount { + self.amount.saturating_sub(self.remaining) + } + + /// Whether the order can no longer be filled at the block time `time`. + pub fn is_expired_at(&self, time: TimestampMillis) -> bool { + self.expires_at.is_some_and(|expires_at| time >= expires_at) + } + + /// The amount a fill asking for between `min_amount` and `max_amount` takes, or `None` when + /// no amount in that range keeps the order's rules. + /// + /// The fill takes as much as it may: everything open, up to `max_amount`. When that would + /// leave less than `min_fill` open, it takes less, leaving exactly `min_fill`, if that still + /// takes at least `min_fill` and `min_amount`. A fill that does not take everything open takes + /// at least `min_fill`. + pub fn fill_amount( + &self, + min_amount: TokenAmount, + max_amount: TokenAmount, + ) -> Option { + let mut take = max_amount.min(self.remaining); + if take < self.remaining { + let left = self.remaining - take; + if left < self.min_fill { + take = self.remaining.checked_sub(self.min_fill)?; + } + if take < self.min_fill { + return None; + } + } + if take == 0 || take < min_amount { + return None; + } + Some(take) + } + + /// The credits a fill of `fill_amount` moves: what a listing's buyer pays, rounded up, or + /// what an offer's seller receives, rounded down, as the change in the order's running total + /// for a token with `decimals` decimals. + pub fn fill_credits( + &self, + fill_amount: TokenAmount, + decimals: u8, + ) -> Result { + let unit = unit(decimals)?; + let filled = self.filled(); + let filled_after = filled + .checked_add(fill_amount) + .ok_or(ProtocolError::Overflow( + "a fill takes more than an order holds", + ))?; + let round_up = matches!(self.side, TokenOrderSide::Ask); + let before = total_at(filled, self.price, unit, round_up); + let after = total_at(filled_after, self.price, unit, round_up); + Credits::try_from(after - before) + .map_err(|_| ProtocolError::Overflow("a fill moves more credits than fit in u64")) + } + + /// What an order of `amount` base units at `price` credits per whole token is worth, rounded + /// up, for a token with `decimals` decimals: everything its buyers pay for a listing, and the + /// escrow an offer holds. `None` when it is more credits than an order holds (`i64::MAX`). + pub fn order_value(amount: TokenAmount, price: Credits, decimals: u8) -> Option { + let unit = unit(decimals).ok()?; + let value = total_at(amount, price, unit, true); + if value > i64::MAX as u128 { + None + } else { + Some(value as Credits) + } + } + + /// Reads an order of `side` from its token market contract document. + pub fn from_document(document: &Document, side: TokenOrderSide) -> Result { + let properties = document.properties(); + let integer = |name: &str| -> Result { + properties + .get(name) + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution(format!( + "a token order document has no {name}" + )) + })? + .to_integer::() + .map_err(ProtocolError::ValueError) + }; + let token_id = properties + .get(property_names::TOKEN_ID) + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "a token order document has no tokenId".to_string(), + ) + })? + .to_identifier() + .map_err(ProtocolError::ValueError)?; + let expires_at = match properties.get(property_names::EXPIRES_AT) { + None | Some(Value::Null) => None, + Some(value) => Some( + value + .to_integer::() + .map_err(ProtocolError::ValueError)?, + ), + }; + let escrow = match side { + TokenOrderSide::Ask => 0, + TokenOrderSide::Bid => integer(property_names::ESCROW)?, + }; + Ok(TokenOrder { + id: document.id(), + side, + maker_id: document.owner_id(), + token_id, + price: integer(property_names::PRICE)?, + amount: integer(property_names::AMOUNT)?, + remaining: integer(property_names::REMAINING)?, + min_fill: integer(property_names::MIN_FILL)?, + expires_at, + escrow, + }) + } + + /// The order's properties as the token market contract stores them: every integer a signed + /// 8-byte value (the contract stores integers as i64). + fn properties(&self) -> Result, ProtocolError> { + let signed = |value: u64| -> Result { + i64::try_from(value) + .map(Value::I64) + .map_err(|_| ProtocolError::Overflow("a token order integer is above i64::MAX")) + }; + let mut properties = BTreeMap::from([ + ( + property_names::TOKEN_ID.to_string(), + Value::Identifier(self.token_id.to_buffer()), + ), + (property_names::PRICE.to_string(), signed(self.price)?), + (property_names::AMOUNT.to_string(), signed(self.amount)?), + ( + property_names::REMAINING.to_string(), + signed(self.remaining)?, + ), + (property_names::MIN_FILL.to_string(), signed(self.min_fill)?), + ]); + if let Some(expires_at) = self.expires_at { + properties.insert(property_names::EXPIRES_AT.to_string(), signed(expires_at)?); + } + if self.side == TokenOrderSide::Bid { + properties.insert(property_names::ESCROW.to_string(), signed(self.escrow)?); + } + Ok(properties) + } + + /// The document of a new order, placed at `block_info`. + pub fn new_document(&self, block_info: &BlockInfo) -> Result { + Ok(DocumentV0 { + contract_version: None, + id: self.id, + owner_id: self.maker_id, + properties: self.properties()?, + revision: Some(1), + created_at: Some(block_info.time_ms), + updated_at: Some(block_info.time_ms), + transferred_at: None, + created_at_block_height: Some(block_info.height), + 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()) + } + + /// The document `stored` after a partial fill left this order's `remaining` (and, for an + /// offer, its `escrow`): the same document at the next revision, updated at `block_info`. + pub fn updated_document( + &self, + stored: &Document, + block_info: &BlockInfo, + ) -> Result { + let revision: Revision = stored.revision().unwrap_or(1).saturating_add(1); + let mut document = stored.clone(); + let Document::V0(ref mut v0) = document; + v0.properties = self.properties()?; + v0.revision = Some(revision); + v0.updated_at = Some(block_info.time_ms); + Ok(document) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn order( + side: TokenOrderSide, + amount: TokenAmount, + remaining: TokenAmount, + min_fill: TokenAmount, + ) -> TokenOrder { + TokenOrder { + id: Identifier::from([1; 32]), + side, + maker_id: Identifier::from([2; 32]), + token_id: Identifier::from([3; 32]), + price: 333, + amount, + remaining, + min_fill, + expires_at: None, + escrow: 0, + } + } + + #[test] + fn should_take_everything_open_up_to_the_fill_maximum() { + let listing = order(TokenOrderSide::Ask, 1000, 1000, 1); + assert_eq!(listing.fill_amount(1, 400), Some(400)); + assert_eq!(listing.fill_amount(1, 5000), Some(1000)); + assert_eq!(listing.fill_amount(1001, 5000), None); + } + + #[test] + fn should_leave_at_least_the_minimum_fill_open() { + let listing = order(TokenOrderSide::Ask, 1000, 1000, 100); + // 950 would leave 50: the fill takes 900 and leaves 100. + assert_eq!(listing.fill_amount(1, 950), Some(900)); + // Unless the fill asked for more than that. + assert_eq!(listing.fill_amount(901, 950), None); + // A fill under the minimum fill that does not take everything is refused. + assert_eq!(listing.fill_amount(1, 50), None); + // Everything open is always takeable. + let tail = order(TokenOrderSide::Ask, 1000, 60, 100); + assert_eq!(tail.fill_amount(1, 1000), Some(60)); + } + + #[test] + fn should_sell_an_all_or_none_listing_only_whole() { + let listing = order(TokenOrderSide::Ask, 1000, 1000, 1000); + assert_eq!(listing.fill_amount(1, 999), None); + assert_eq!(listing.fill_amount(1, 1000), Some(1000)); + } + + /// 2 decimals at 333 credits per token is 3.33 credits per base unit. + #[test] + fn should_charge_the_running_total_so_splitting_never_changes_the_cost() { + let mut listing = order(TokenOrderSide::Ask, 3, 3, 1); + let mut paid = vec![]; + for _ in 0..3 { + let credits = listing.fill_credits(1, 2).expect("expected the cost"); + paid.push(credits); + listing.remaining -= 1; + } + assert_eq!(paid, vec![4, 3, 3]); + let whole = order(TokenOrderSide::Ask, 3, 3, 1); + assert_eq!(whole.fill_credits(3, 2).expect("expected the cost"), 10); + } + + #[test] + fn should_round_an_offers_payouts_down_and_keep_them_within_its_escrow() { + let escrow = TokenOrder::order_value(1001, 333, 2).expect("expected a value"); + assert_eq!(escrow, 3334); + let mut offer = order(TokenOrderSide::Bid, 1001, 1001, 1); + let mut paid = 0; + while offer.remaining > 0 { + let take = offer.remaining.min(7); + paid += offer.fill_credits(take, 2).expect("expected the payout"); + offer.remaining -= take; + } + assert_eq!(paid, 3333); + assert!(paid <= escrow); + } + + #[test] + fn should_refuse_an_order_worth_more_than_an_order_holds() { + assert_eq!(TokenOrder::order_value(i64::MAX as u64, 2, 0), None); + assert_eq!( + TokenOrder::order_value(i64::MAX as u64, 1, 0), + Some(i64::MAX as u64) + ); + } + + #[test] + fn should_round_trip_through_its_document() { + let mut offer = order(TokenOrderSide::Bid, 1000, 400, 10); + offer.escrow = 1998; + offer.expires_at = Some(5_000); + let block_info = BlockInfo::default(); + let document = offer + .new_document(&block_info) + .expect("expected a document"); + assert_eq!( + TokenOrder::from_document(&document, TokenOrderSide::Bid).expect("expected an order"), + offer + ); + assert_eq!(document.revision(), Some(1)); + + offer.remaining = 100; + let updated = offer + .updated_document(&document, &block_info) + .expect("expected a document"); + assert_eq!(updated.revision(), Some(2)); + assert_eq!( + TokenOrder::from_document(&updated, TokenOrderSide::Bid) + .expect("expected an order") + .remaining, + 100 + ); + } +} diff --git a/packages/rs-dpp/src/tokens/token_order_side.rs b/packages/rs-dpp/src/tokens/token_order_side.rs new file mode 100644 index 00000000000..c5960a5d2a2 --- /dev/null +++ b/packages/rs-dpp/src/tokens/token_order_side.rs @@ -0,0 +1,87 @@ +use crate::document::Document; +use crate::prelude::IdentityNonce; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The side of a token marketplace order (protocol version 15): a listing (`Ask`), which holds +/// the tokens it offers for sale in escrow, or an offer (`Bid`), which holds the credits it offers +/// in escrow. Each side is its own document type of the token market contract. +#[derive( + Debug, + Clone, + Copy, + Default, + Encode, + Decode, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub enum TokenOrderSide { + /// A listing: tokens for sale at a price. + #[default] + Ask = 0, + /// An offer: credits to buy tokens at a price. + Bid = 1, +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenOrderSide {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenOrderSide {} + +impl TokenOrderSide { + /// The token market contract document type that holds the orders of this side. + pub fn document_type_name(&self) -> &'static str { + match self { + TokenOrderSide::Ask => "ask", + TokenOrderSide::Bid => "bid", + } + } +} + +impl fmt::Display for TokenOrderSide { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TokenOrderSide::Ask => write!(f, "ask"), + TokenOrderSide::Bid => write!(f, "bid"), + } + } +} + +impl TokenOrderSide { + /// The id of the order of this side that `maker` places for `token_id` with the identity + /// contract nonce `nonce`: known before the transition is broadcast, and unique, since the + /// nonce is used once. It is the id of the order's document in the token market contract and + /// the key of its escrow entry. The token id takes the contract id's place, as it does in + /// the ids of token history documents. + pub fn order_id( + &self, + token_id: Identifier, + maker: Identifier, + nonce: IdentityNonce, + ) -> Identifier { + Document::generate_document_id_v0( + &token_id, + &maker, + self.document_type_name(), + nonce.to_be_bytes().as_slice(), + ) + } +} 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 7bf11d9ae27..ca9f49402a5 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 @@ -169,12 +169,19 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { } } 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. + // The token marketplace: the order kinds, and a configuration update that + // makes a token tradeable. Software older than the marketplace cannot decode + // either, so both are refused unpaid, as that software refuses them. if let Some(transition) = st.transitions_iter() .find_map(|transition| match transition { + // Asked as one question, like the pool kinds above, so that an + // order kind added later is gated without being listed here. + BatchedTransitionRef::Token(token_transition) + if token_transition.is_marketplace_order() => + { + Some(format!("Token{}", token_transition.action_type())) + } BatchedTransitionRef::Token(TokenTransition::ConfigUpdate( config_update, )) if matches!( @@ -786,7 +793,10 @@ mod tests { 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_cancel_order_transition::{TokenCancelOrderTransition, TokenCancelOrderTransitionV0}; use dpp::state_transition::batch_transition::batched_transition::token_config_update_transition::{TokenConfigUpdateTransition, TokenConfigUpdateTransitionV0}; + use dpp::state_transition::batch_transition::batched_transition::token_fill_ask_transition::{TokenFillAskTransition, TokenFillAskTransitionV0}; + use dpp::state_transition::batch_transition::batched_transition::token_place_ask_transition::{TokenPlaceAskTransition, TokenPlaceAskTransitionV0}; 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; @@ -923,5 +933,72 @@ mod tests { .expect("should not error"); assert!(result.is_valid(), "{:?}", result.errors); } + + /// A batch whose only transition is `token_transition`. + fn batch_with(token_transition: TokenTransition) -> StateTransition { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Default::default(), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::default(), + })) + } + + /// Every order kind, with the name the gate reports it under. + fn every_order_kind() -> Vec<(TokenTransition, &'static str)> { + vec![ + ( + TokenTransition::PlaceAsk(TokenPlaceAskTransition::V0( + TokenPlaceAskTransitionV0::default(), + )), + "TokenPlaceAsk", + ), + ( + TokenTransition::FillAsk(TokenFillAskTransition::V0( + TokenFillAskTransitionV0::default(), + )), + "TokenFillAsk", + ), + ( + TokenTransition::CancelOrder(TokenCancelOrderTransition::V0( + TokenCancelOrderTransitionV0::default(), + )), + "TokenCancelOrder", + ), + ] + } + + /// Software older than the marketplace cannot decode an order kind, so each is refused + /// unpaid before protocol version 15, under the name a client is told. + #[test] + fn should_refuse_every_order_kind_before_protocol_version_15() { + for (token_transition, expected_name) in every_order_kind() { + let result = batch_with(token_transition) + .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() == expected_name + && error.required_protocol_version() + == TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, + "expected {expected_name} to be refused, got {:?}", + result.errors + ); + } + } + + #[test] + fn should_allow_every_order_kind_from_protocol_version_15() { + let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) + .expect("the version that introduced the token marketplace"); + for (token_transition, name) in every_order_kind() { + let result = batch_with(token_transition) + .validate_is_allowed(platform_version) + .expect("should not error"); + assert!(result.is_valid(), "{name}: {:?}", result.errors); + } + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 6be3f347c16..21c7b4a9c6f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -1154,7 +1154,10 @@ fn validate_batch_token_shielded_proofs( | TokenTransition::EmergencyAction(_) | TokenTransition::ConfigUpdate(_) | TokenTransition::DirectPurchase(_) - | TokenTransition::SetPriceForDirectPurchase(_), + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_), ) => continue, }; if let Err(e) = result { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs index 2870bd81b70..c85918925bf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod token_base_transition_action; pub(crate) mod token_burn_from_pool_transition_action; pub(crate) mod token_burn_transition_action; +pub(crate) mod token_cancel_order_transition_action; pub(crate) mod token_claim_to_pool_transition_action; pub(crate) mod token_claim_transition_action; pub(crate) mod token_config_update_transition_action; @@ -8,9 +9,12 @@ pub(crate) mod token_destroy_frozen_funds_transition_action; pub(crate) mod token_direct_purchase_to_pool_transition_action; pub(crate) mod token_direct_purchase_transition_action; pub(crate) mod token_emergency_action_transition_action; +pub(crate) mod token_fill_ask_transition_action; pub(crate) mod token_freeze_transition_action; pub(crate) mod token_mint_to_pool_transition_action; pub(crate) mod token_mint_transition_action; +pub(crate) mod token_order_common; +pub(crate) mod token_place_ask_transition_action; pub(crate) mod token_set_price_for_direct_purchase_transition_action; pub(crate) mod token_shield_transition_action; pub(crate) mod token_shielded_pool_common; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs new file mode 100644 index 00000000000..9c4bc6f2bdc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs @@ -0,0 +1,59 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +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_cancel_order_transition_action::state_v0::TokenCancelOrderTransitionActionStateValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; + +pub trait TokenCancelOrderTransitionActionValidation { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionActionValidation for TokenCancelOrderTransitionAction { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_cancel_order_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenCancelOrderTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..36d0682bd62 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs @@ -0,0 +1,46 @@ +use dpp::block::block_info::BlockInfo; +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_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenCancelOrderTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionActionStateValidationV0 for TokenCancelOrderTransitionAction { + /// The transformer already refused an order that is not open or not the sender's. A cancel + /// needs nothing more: it returns the escrow to its maker even when the token is paused, no + /// longer tradeable or the maker's account is frozen, so an order can always be withdrawn. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs new file mode 100644 index 00000000000..ed9a31f7057 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs @@ -0,0 +1,59 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +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_fill_ask_transition_action::state_v0::TokenFillAskTransitionActionStateValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; + +pub trait TokenFillAskTransitionActionValidation { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionValidation for TokenFillAskTransitionAction { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..74da60be4b7 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs @@ -0,0 +1,108 @@ +use dpp::block::block_info::BlockInfo; +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_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::{validate_token_account_not_frozen, validate_token_tradeable}; +use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenFillAskTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionStateValidationV0 for TokenFillAskTransitionAction { + /// The transformer already read the listing and refused a fill it cannot meet. A fill also + /// needs a token that is still tradeable and not paused, a seller whose account is not + /// frozen, and a buyer whose account is not frozen unless the token allows transfers to + /// frozen balances. The buyer's credit balance is checked with the batch's fees, which count + /// the fill's cost as a purchase. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_tradeable(self.base())?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_not_paused( + platform, + self.token_id(), + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + self.order().maker_id, + "fillAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + if !self + .base() + .token_configuration()? + .is_allowed_transfer_to_frozen_balance() + { + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + owner_id, + "fillAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs new file mode 100644 index 00000000000..9ff10c6f352 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs @@ -0,0 +1,69 @@ +//! Stateful checks shared by the token marketplace validators: the token must be tradeable and +//! an identity whose tokens a listing moves must not be frozen. Every read is metered into the +//! execution context, so the batch owner pays for it. + +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::token::{IdentityTokenAccountFrozenError, TokenNotTradeableError}; +use dpp::consensus::ConsensusError; +use dpp::prelude::Identifier; +use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ + TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0, +}; + +/// The token's configuration must allow trading (`marketplaceRules.tradeMode` `Tradeable`). A +/// token whose trade mode changes back to `NotTradeable` keeps its open orders, which can still +/// be cancelled but no longer filled. +pub(crate) fn validate_token_tradeable( + base: &TokenBaseTransitionAction, +) -> Result { + if !base.token_configuration()?.is_tradeable() { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenNotTradeableError::new(base.token_id()).into(), + )); + } + Ok(SimpleConsensusValidationResult::new()) +} + +/// The token account of `identity_id` must not be frozen. +#[allow(clippy::too_many_arguments)] +pub(crate) fn validate_token_account_not_frozen( + platform: &PlatformStateRef, + token_id: Identifier, + identity_id: Identifier, + action: &str, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + let (info, fee_result) = platform.drive.fetch_identity_token_info_with_costs( + token_id.to_buffer(), + identity_id.to_buffer(), + block_info, + true, + transaction, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if info.is_some_and(|info| info.frozen()) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError( + IdentityTokenAccountFrozenError::new(token_id, identity_id, action.to_string()), + )), + )); + } + Ok(SimpleConsensusValidationResult::new()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs new file mode 100644 index 00000000000..536c081aa56 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs @@ -0,0 +1,59 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +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_place_ask_transition_action::state_v0::TokenPlaceAskTransitionActionStateValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; + +pub trait TokenPlaceAskTransitionActionValidation { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionValidation for TokenPlaceAskTransitionAction { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..f2c48fe7ce3 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs @@ -0,0 +1,135 @@ +use dpp::block::block_info::BlockInfo; +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_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, TokenOrderExpiredError, TokenOrderValueTooLargeError}; +use dpp::consensus::ConsensusError; +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::tokens::token_order::TokenOrder; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::{validate_token_account_not_frozen, validate_token_tradeable}; +use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenPlaceAskTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionStateValidationV0 for TokenPlaceAskTransitionAction { + /// A listing needs a tradeable token that is not paused, a seller whose account is not + /// frozen and who holds the amount, a total that fits in an order (`i64::MAX` credits) and, + /// when it expires, an expiry after the block. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_tradeable(self.base())?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let decimals = self.base().token_configuration()?.conventions().decimals(); + if TokenOrder::order_value(self.amount(), self.price(), decimals).is_none() { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenOrderValueTooLargeError::new(self.amount(), self.price(), decimals).into(), + )); + } + + if let Some(expires_at) = self.expires_at() { + if expires_at <= block_info.time_ms { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenOrderExpiredError::new(self.order_id(), expires_at, block_info.time_ms) + .into(), + )); + } + } + + let validation_result = validate_token_not_paused( + platform, + self.token_id(), + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + owner_id, + "placeAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let balance = platform + .drive + .fetch_identity_token_balance( + self.token_id().to_buffer(), + owner_id.to_buffer(), + transaction, + platform_version, + )? + .unwrap_or_default(); + + execution_context.add_operation(ValidationOperation::RetrieveIdentityTokenBalance); + + if balance < self.amount() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::IdentityDoesNotHaveEnoughTokenBalanceError( + IdentityDoesNotHaveEnoughTokenBalanceError::new( + self.token_id(), + owner_id, + self.amount(), + balance, + "placeAsk".to_string(), + ), + )), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs index 6a51c5576f5..50a44061c3e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs @@ -68,7 +68,10 @@ impl TokenPoolNullifiers { | TokenTransitionAction::DestroyFrozenFundsAction(_) | TokenTransitionAction::ConfigUpdateAction(_) | TokenTransitionAction::DirectPurchaseAction(_) - | TokenTransitionAction::SetPriceForDirectPurchaseAction(_) => return None, + | TokenTransitionAction::SetPriceForDirectPurchaseAction(_) + | TokenTransitionAction::PlaceAskAction(_) + | TokenTransitionAction::FillAskAction(_) + | TokenTransitionAction::CancelOrderAction(_) => return None, }, BatchedTransitionAction::DocumentAction(document) => { let base = match document { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs index 4ba8c74a7dd..93b8331172e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs @@ -41,6 +41,9 @@ use crate::execution::validation::state_transition::batch::action_validation::to use crate::execution::validation::state_transition::batch::action_validation::token::token_burn_from_pool_transition_action::TokenBurnFromPoolTransitionActionValidation; use crate::execution::validation::state_transition::batch::action_validation::token::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionActionValidation; use crate::execution::validation::state_transition::batch::action_validation::token::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_cancel_order_transition_action::TokenCancelOrderTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::TokenFillAskTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::TokenPlaceAskTransitionActionValidation; use crate::execution::validation::state_transition::batch::data_triggers::{data_trigger_bindings_list, DataTriggerExecutionContext, DataTriggerExecutor}; use crate::execution::validation::state_transition::batch::state::v0::accepted_token_pool_nullifiers::{AcceptedTokenPoolNullifiers, TokenPoolNullifiers}; use crate::execution::validation::state_transition::batch::state::v0::added_moderator_cap::AddedModeratorCap; @@ -400,6 +403,36 @@ impl DocumentsBatchStateTransitionStateValidationV0 for BatchTransition { transaction, platform_version, )?, + // The token marketplace arms are reached only from protocol version 15: the + // batch's `is_allowed` gate refuses every order kind before it. + TokenTransitionAction::PlaceAskAction(place_ask_action) => place_ask_action + .validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?, + TokenTransitionAction::FillAskAction(fill_ask_action) => fill_ask_action + .validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?, + TokenTransitionAction::CancelOrderAction(cancel_order_action) => { + cancel_order_action.validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )? + } }, BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs new file mode 100644 index 00000000000..e671936b2a1 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -0,0 +1,796 @@ +//! Token marketplace listings (protocol version 15): a holder of a tradeable token lists an +//! amount for sale (`PlaceAsk`), which moves the tokens into escrow and records an `ask` in the +//! token market contract; a buyer takes part or all of it (`FillAsk`), paying the seller in +//! credits; the seller withdraws what is left (`CancelOrder`). +use super::*; +use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Setters; +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::DataContract; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::prelude::{Identity, IdentityPublicKey}; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; +use dpp::tokens::emergency_action::TokenEmergencyAction; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use drive::drive::Drive; +use simple_signer::signer::SimpleSigner; +use std::sync::Arc; + +/// 10 credits per base unit of a token with 8 decimals. +const PRICE: Credits = 1_000_000_000; + +/// The basic token contract gives its owner this many base units. +const BASE_SUPPLY: u64 = 100_000; + +fn make_token_tradeable(token_configuration: &mut TokenConfiguration) { + token_configuration + .as_v0_mut() + .marketplace_rules + .set_trade_mode(TokenTradeMode::Tradeable); +} + +fn make_token_tradeable_and_pausable(token_configuration: &mut TokenConfiguration) { + make_token_tradeable(token_configuration); + token_configuration.set_emergency_action_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + })); +} + +struct Party { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The next identity contract nonce of this party for the token's contract. + nonce: u64, +} + +impl Party { + fn id(&self) -> Identifier { + self.identity.id() + } + + fn take_nonce(&mut self) -> u64 { + let nonce = self.nonce; + self.nonce += 1; + nonce + } +} + +struct Market { + platform: TempPlatform, + contract: DataContract, + token_id: Identifier, + /// The contract owner, who holds the whole base supply. + seller: Party, + buyer: Party, +} + +fn market_with( + token_configuration_modification: impl FnOnce(&mut TokenConfiguration), + buyer_credits: Credits, +) -> Market { + 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(70415); + let (seller, seller_signer, seller_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(1.0)); + let (buyer, buyer_signer, buyer_key) = setup_identity(&mut platform, rng.gen(), buyer_credits); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + seller.id(), + Some(token_configuration_modification), + None, + None, + None, + platform_version, + ); + + Market { + platform, + contract, + token_id, + seller: Party { + identity: seller, + signer: seller_signer, + key: seller_key, + nonce: 2, + }, + buyer: Party { + identity: buyer, + signer: buyer_signer, + key: buyer_key, + nonce: 2, + }, + } +} + +fn tradeable_market() -> Market { + market_with(make_token_tradeable, dash_to_credits!(1.0)) +} + +fn at_time(time_ms: u64) -> BlockInfo { + BlockInfo { + time_ms, + ..BlockInfo::default() + } +} + +impl Market { + fn process_at( + &mut self, + transition: &StateTransition, + block_info: &BlockInfo, + ) -> StateTransitionsProcessingResult { + let platform_version = PlatformVersion::latest(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch to serialize"); + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the batch"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the batch"); + processing_result + } + + fn process(&mut self, transition: &StateTransition) -> StateTransitionsProcessingResult { + self.process_at(transition, &BlockInfo::default()) + } + + fn assert_executes(&mut self, transition: &StateTransition, block_info: &BlockInfo) { + let processing_result = self.process_at(transition, block_info); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "expected the batch to execute: {:?}", + processing_result.execution_results() + ); + } + + /// A listing by the seller, and its order id. + async fn place_ask( + &mut self, + amount: u64, + price: Credits, + min_fill: u64, + expires_at: Option, + ) -> (StateTransition, Identifier) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.seller.id(), nonce); + let transition = BatchTransition::new_token_place_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + amount, + price, + min_fill, + expires_at, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing"); + (transition, order_id) + } + + /// A fill by the buyer. + async fn fill_ask( + &mut self, + order_id: Identifier, + min_amount: u64, + max_amount: u64, + max_total_price: Credits, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.buyer.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.buyer.id(), + self.contract.id(), + 0, + order_id, + min_amount, + max_amount, + max_total_price, + &self.buyer.key, + nonce, + 0, + &self.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + /// A fill by the seller of their own listing. + async fn seller_fill_ask(&mut self, order_id: Identifier) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + order_id, + 1, + u64::MAX >> 1, + u64::MAX >> 1, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + /// A cancel by the seller, or by the buyer when `by_buyer`. + async fn cancel(&mut self, order_id: Identifier, by_buyer: bool) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let party = if by_buyer { + &mut self.buyer + } else { + &mut self.seller + }; + let nonce = party.take_nonce(); + BatchTransition::new_token_cancel_order_transition( + self.token_id, + party.id(), + self.contract.id(), + 0, + order_id, + TokenOrderSide::Ask, + &party.key, + nonce, + 0, + &party.signer, + platform_version, + None, + ) + .await + .expect("expected to build the cancel") + } + + async fn pause(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_emergency_action_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenEmergencyAction::Pause, + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the pause"); + self.assert_executes(&transition, &BlockInfo::default()); + } + + fn token_balance(&self, identity_id: Identifier) -> Option { + self.platform + .drive + .fetch_identity_token_balance( + self.token_id.to_buffer(), + identity_id.to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the token balance") + } + + fn credit_balance(&self, identity_id: Identifier) -> Credits { + self.platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, PlatformVersion::latest()) + .expect("expected to fetch the credit balance") + .expect("expected the identity to have a balance") + } + + fn escrow(&self, order_id: Identifier) -> Option { + self.platform + .drive + .fetch_token_order_escrow( + self.token_id.to_buffer(), + order_id.to_buffer(), + None, + &mut vec![], + PlatformVersion::latest(), + ) + .expect("expected to fetch the escrow") + } + + fn listing(&self, order_id: Identifier) -> Option { + let platform_version = PlatformVersion::latest(); + let token_market_contract = self + .platform + .drive + .token_market_contract_fetch_info(platform_version) + .expect("expected the token market contract"); + let (document, _) = self + .platform + .drive + .fetch_token_order_document( + &token_market_contract.contract, + TokenOrderSide::Ask, + order_id, + &BlockInfo::default().epoch, + None, + platform_version, + ) + .expect("expected to fetch the listing"); + document.map(|document| { + TokenOrder::from_document(&document, TokenOrderSide::Ask) + .expect("expected the listing to read as an order") + }) + } + + fn assert_supply_balanced(&self) { + let total = self + .platform + .drive + .calculate_total_tokens_balance(None, PlatformVersion::latest()) + .expect("expected to calculate the total tokens balance"); + assert!( + total.ok().expect("expected the totals to add up"), + "expected the token supply to stay balanced: {total:?}" + ); + } + + /// Proves the executed transition the way the node answers a wait, and verifies the proof + /// the way the SDK does. + fn prove_and_verify(&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 executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(self.contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + outcome.into_result() + } +} + +#[tokio::test] +async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 100, Some(9_000)).await; + market.assert_executes(&place, &BlockInfo::default()); + + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + let listing = market.listing(order_id).expect("expected the listing"); + assert_eq!( + listing, + TokenOrder { + id: order_id, + side: TokenOrderSide::Ask, + maker_id: market.seller.id(), + token_id: market.token_id, + price: PRICE, + amount: 1_000, + remaining: 1_000, + min_fill: 100, + expires_at: Some(9_000), + escrow: 0, + } + ); + market.assert_supply_balanced(); + + let StateTransitionProofResult::VerifiedDocuments(documents) = market.prove_and_verify(&place) + else { + panic!("expected the listing's document to be proved"); + }; + let document: &Document = documents + .get(&order_id) + .and_then(Option::as_ref) + .expect("expected the proof to hold the listing"); + assert_eq!( + TokenOrder::from_document(document, TokenOrderSide::Ask).expect("expected an order"), + listing + ); +} + +#[tokio::test] +async fn should_sell_part_of_a_listing_and_then_the_rest() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 100, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + let buyer_credits = market.credit_balance(market.buyer.id()); + + // 400 base units at 10 credits each. + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + assert_eq!(market.token_balance(market.buyer.id()), Some(400)); + assert_eq!(market.escrow(order_id), Some(600)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(600) + ); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 4_000, + "the seller receives the price and pays nothing for the fill" + ); + let buyer_paid = buyer_credits - market.credit_balance(market.buyer.id()); + assert!( + buyer_paid > 4_000, + "the buyer pays the price and the fee, paid {buyer_paid}" + ); + market.assert_supply_balanced(); + assert_matches!( + market.prove_and_verify(&fill), + StateTransitionProofResult::VerifiedTokenBalance(buyer_id, 400) if buyer_id == market.buyer.id() + ); + + // A fill may ask for more than is open: it takes what is left. + let fill = market.fill_ask(order_id, 1, 10_000, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None, "a sold-out listing is gone"); + // The seller receives the price, and the storage of the listing they paid for. + let seller_gain = market.credit_balance(market.seller.id()) - seller_credits; + assert!( + seller_gain > 10_000, + "expected the price and the listing's storage refund, got {seller_gain}" + ); + market.assert_supply_balanced(); +} + +/// 1.5 credits per base unit: two fills of 3 cost 5 and 4, the 9 the 6 units are worth, as +/// each fill pays the change in the listing's rounded-up running total. The listing keeps one +/// unit open, so no storage refund mixes into the seller's balance. +#[tokio::test] +async fn should_charge_each_fill_the_change_in_the_running_total() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(7, 150_000_000, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 3, 3, 5).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 5 + ); + + // The second half costs 4, so a limit of 4 is enough. + let fill = market.fill_ask(order_id, 3, 3, 4).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 9 + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(6)); + assert_eq!(market.escrow(order_id), Some(1)); +} + +#[tokio::test] +async fn should_return_the_tokens_of_a_cancelled_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 250, 2_500).await; + market.assert_executes(&fill, &BlockInfo::default()); + + let cancel = market.cancel(order_id, false).await; + market.assert_executes(&cancel, &BlockInfo::default()); + + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 250) + ); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); + market.assert_supply_balanced(); + assert_matches!( + market.prove_and_verify(&cancel), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.len() == 1 && documents.get(&order_id) == Some(&None) + ); +} + +#[tokio::test] +async fn should_refuse_listing_a_token_that_is_not_tradeable() { + let mut market = market_with(|_| {}, dash_to_credits!(1.0)); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError(StateError::TokenNotTradeableError(inner)), + .. + }] if inner.token_id() == market.token_id && error.code() == 40750 + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +#[tokio::test] +async fn should_refuse_listing_more_than_the_seller_holds() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE, 1, None).await; + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityDoesNotHaveEnoughTokenBalanceError(_) + ), + .. + }] + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +#[tokio::test] +async fn should_refuse_a_listing_worth_more_than_an_order_holds() { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration.conventions_mut().set_decimals(0); + }, + dash_to_credits!(1.0), + ); + // Two whole tokens at i64::MAX credits each. + let (place, _) = market.place_ask(2, i64::MAX as u64, 1, None).await; + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError(StateError::TokenOrderValueTooLargeError(_)), + .. + }] if error.code() == 40757 + ); +} + +#[tokio::test] +async fn should_refuse_a_listing_that_expires_before_the_block() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(5_000)).await; + let processing_result = market.process_at(&place, &at_time(5_000)); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderExpiredError(inner)), + .. + }] if inner.order_id() == order_id + ); + assert_eq!(market.listing(order_id), None); +} + +#[tokio::test] +async fn should_refuse_filling_an_expired_listing_but_still_cancel_it() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(5_000)).await; + market.assert_executes(&place, &at_time(1_000)); + + let fill = market.fill_ask(order_id, 1, 1_000, 10_000).await; + let processing_result = market.process_at(&fill, &at_time(5_000)); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError(StateError::TokenOrderExpiredError(_)), + .. + }] if error.code() == 40752 + ); + assert_eq!(market.token_balance(market.buyer.id()), None); + + let cancel = market.cancel(order_id, false).await; + market.assert_executes(&cancel, &at_time(5_000)); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); +} + +#[tokio::test] +async fn should_refuse_filling_ones_own_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.seller_fill_ask(order_id).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderSelfFillError(_)), + .. + }] + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +#[tokio::test] +async fn should_refuse_a_fill_above_the_buyers_price_limit() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.fill_ask(order_id, 1, 400, 3_999).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderPriceLimitError(inner)), + .. + }] if inner.total() == 4_000 && inner.limit() == 3_999 + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +/// A listing of 1,000 with a minimum fill of 100: a fill of 950 would leave 50 open, so the +/// fill takes 900 instead, which a buyer asking for at least 950 does not accept. +#[tokio::test] +async fn should_refuse_a_fill_the_minimum_fill_cannot_meet() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 100, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.fill_ask(order_id, 950, 950, 1_000_000).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderFillAmountError(_)), + .. + }] + ); + + // Accepting 900 is enough. + let fill = market.fill_ask(order_id, 900, 950, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!(market.token_balance(market.buyer.id()), Some(900)); + assert_eq!(market.escrow(order_id), Some(100)); +} + +#[tokio::test] +async fn should_refuse_filling_a_listing_that_does_not_exist() { + let mut market = tradeable_market(); + let fill = market + .fill_ask(Identifier::from([7; 32]), 1, 1_000, 1_000_000) + .await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderNotFoundError(_)), + .. + }] + ); +} + +#[tokio::test] +async fn should_refuse_cancelling_someone_elses_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let cancel = market.cancel(order_id, true).await; + let processing_result = market.process(&cancel); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderNotOwnedError(_)), + .. + }] + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +#[tokio::test] +async fn should_refuse_a_fill_of_a_paused_token_but_still_cancel() { + let mut market = market_with(make_token_tradeable_and_pausable, dash_to_credits!(1.0)); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + market.pause().await; + + let fill = market.fill_ask(order_id, 1, 1_000, 10_000).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenIsPausedError(_)), + .. + }] + ); + + let cancel = market.cancel(order_id, false).await; + market.assert_executes(&cancel, &BlockInfo::default()); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); +} + +#[tokio::test] +async fn should_not_fill_a_listing_the_buyer_cannot_pay_for() { + let mut market = market_with(make_token_tradeable, dash_to_credits!(0.0001)); + // 10,000,000 credits, more than the buyer's balance. + let (place, order_id) = market.place_ask(1_000, 1_000_000_000_000, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.fill_ask(order_id, 1, 1_000, u64::MAX >> 1).await; + let processing_result = market.process(&fill); + assert!( + !matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ), + "a fill the buyer cannot pay for must not execute: {:?}", + processing_result.execution_results() + ); + assert_eq!(market.token_balance(market.buyer.id()), None); + assert_eq!(market.escrow(order_id), Some(1_000)); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs index aa063c35a84..9722eba58bd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs @@ -6,6 +6,7 @@ mod direct_selling; mod distribution; mod emergency_action; mod freeze; +mod marketplace; mod mint; mod mint_many; mod non_transferable; 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..28dffa7ae8a 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 @@ -110,6 +110,9 @@ use drive::state_transition_action::batch::batched_transition::token_transition: use drive::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_set_price_for_direct_purchase_transition_action::TokenSetPriceForDirectPurchaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_unfreeze_transition_action::TokenUnfreezeTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; @@ -842,6 +845,39 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(data_contract_fetch_info.clone()) }, platform_version)?; + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + // The token marketplace arms below are refused before they are reached on protocol + // versions 1 through 14: the batch's `is_allowed` gate rejects each order kind unpaid + // until protocol version 15. + TokenTransition::PlaceAsk(token_place_ask) => { + let (batched_action, fee_result) = TokenPlaceAskTransitionAction::try_from_borrowed_token_place_ask_transition_with_contract_lookup(drive, owner_id, token_place_ask, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::FillAsk(token_fill_ask) => { + let (batched_action, fee_result) = TokenFillAskTransitionAction::try_from_borrowed_token_fill_ask_transition_with_contract_lookup(drive, owner_id, token_fill_ask, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::CancelOrder(token_cancel_order) => { + let (batched_action, fee_result) = TokenCancelOrderTransitionAction::try_from_borrowed_token_cancel_order_transition_with_contract_lookup(drive, owner_id, token_cancel_order, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); diff --git a/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs b/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs index f5f6ecab283..bcb7aed3398 100644 --- a/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs @@ -261,7 +261,10 @@ impl Platform { | TokenEvent::Unshield(..) | TokenEvent::ShieldedTransfer | TokenEvent::ClaimToPool(..) - | TokenEvent::DirectPurchaseToPool(..) => { + | TokenEvent::DirectPurchaseToPool(..) + | TokenEvent::OrderPlaced(..) + | TokenEvent::OrderFilled(..) + | TokenEvent::OrderCancelled(..) => { return None; }, }, diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs new file mode 100644 index 00000000000..ab4a967138a --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs @@ -0,0 +1,85 @@ +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; +use crate::drive::Drive; +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::platform_value::Value; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use grovedb_costs::OperationCost; +use std::sync::Arc; + +impl Drive { + /// The token market contract materialized for `platform_version`, wrapped for the document + /// operations of the order transitions. A system contract is stored without storage flags, + /// and the order transitions never bill reading it: it is held by the system contract cache. + pub fn token_market_contract_fetch_info( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let contract = self + .cache + .system_data_contracts + .load_token_market(platform_version)?; + Ok(Arc::new(DataContractFetchInfo { + contract: (*contract).clone(), + storage_flags: None, + cost: OperationCost::default(), + fee: None, + })) + } + + /// The open order `order_id` of `side` in the token market contract, with the processing + /// cost of the read, billed at `epoch`. `None` when no open order of that side has the id: + /// it never existed, was filled entirely or was cancelled. + pub fn fetch_token_order_document( + &self, + token_market_contract: &DataContract, + side: TokenOrderSide, + order_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, u64), Error> { + let document_type = + token_market_contract.document_type_for_name(side.document_type_name())?; + let query = DriveDocumentQuery { + contract: token_market_contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(order_id.to_buffer()), + }), + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let outcome = self.query_documents( + query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + let cost = outcome.cost(); + Ok((outcome.documents_owned().into_iter().next(), cost)) + } +} 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..80425a5abe7 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -7,6 +7,7 @@ mod credits; mod estimated_costs; +mod fetch_order; mod insert_trees; mod tokens; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs index dd5b6122a24..ad70082792c 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs @@ -1,5 +1,6 @@ mod token_burn_from_pool_transition; mod token_burn_transition; +mod token_cancel_order_transition; mod token_claim_to_pool_transition; mod token_claim_transition; mod token_config_update_transition; @@ -7,9 +8,11 @@ mod token_destroy_frozen_funds_transition; mod token_direct_purchase_to_pool_transition; mod token_direct_purchase_transition; mod token_emergency_action_transition; +mod token_fill_ask_transition; mod token_freeze_transition; mod token_mint_to_pool_transition; mod token_mint_transition; +mod token_place_ask_transition; mod token_set_price_for_direct_purchase_transition; mod token_shield_transition; mod token_shielded_transfer_transition; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs new file mode 100644 index 00000000000..e49d773f74f --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs @@ -0,0 +1,85 @@ +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; + +impl DriveHighLevelBatchOperationConverter for TokenCancelOrderTransitionAction { + /// Gives the maker back what the order holds in escrow and deletes its document, whose + /// storage goes back to the maker. + fn into_high_level_batch_drive_operations<'b>( + self, + _epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_cancel_order_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let order = self.order(); + let side = order.side; + + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; + + match side { + TokenOrderSide::Ask => { + ops.push(TokenOperation( + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id: self.token_id(), + order_id: order.id, + recipient_id: owner_id, + amount: order.remaining, + remaining_after: 0, + }, + )); + } + TokenOrderSide::Bid => { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "an offer cannot be cancelled before offers can be placed", + ))); + } + } + + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: order.id, + contract_info: DataContractInfo::DataContractFetchInfo( + self.token_market_contract().clone(), + ), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + side.document_type_name(), + ), + })); + + Ok(ops) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: + "TokenCancelOrderTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs new file mode 100644 index 00000000000..64587959831 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs @@ -0,0 +1,113 @@ +use std::borrow::Cow; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; + +impl DriveHighLevelBatchOperationConverter for TokenFillAskTransitionAction { + /// Moves the amount the fill takes from the listing's escrow to the buyer and the credits it + /// costs from the buyer to the seller. A partial fill rewrites the listing's `ask` document; + /// a fill that takes everything open deletes it, and its storage goes back to the seller. + fn into_high_level_batch_drive_operations<'b>( + self, + epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_fill_ask_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let order = self.order(); + let maker_id = order.maker_id; + let order_id = order.id; + let credits = self.credits(); + + let mut ops = vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }), + TokenOperation(TokenOperationType::TokenReleaseFromOrderEscrow { + token_id: self.token_id(), + order_id, + recipient_id: owner_id, + amount: self.fill_amount(), + remaining_after: self.remaining_after(), + }), + ]; + + if credits > 0 { + ops.push(IdentityOperation( + IdentityOperationType::RemoveFromIdentityBalance { + identity_id: owner_id.to_buffer(), + balance_to_remove: credits, + }, + )); + ops.push(IdentityOperation( + IdentityOperationType::AddToIdentityBalance { + identity_id: maker_id.to_buffer(), + added_balance: credits, + }, + )); + } + + let contract_info = + DataContractInfo::DataContractFetchInfo(self.token_market_contract().clone()); + let document_type_info = DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ); + match self.updated_order_document() { + Some(updated_document) => { + let storage_flags = + StorageFlags::new_single_epoch(epoch.index, Some(maker_id.to_buffer())); + ops.push(DocumentOperation(DocumentOperationType::UpdateDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + updated_document.clone(), + Some(Cow::Owned(storage_flags)), + )), + owner_id: Some(maker_id.into_buffer()), + }, + contract_info, + document_type_info, + })); + } + None => { + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: order_id, + contract_info, + document_type_info, + })); + } + } + + Ok(ops) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs new file mode 100644 index 00000000000..b033fe215bf --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs @@ -0,0 +1,83 @@ +use std::borrow::Cow; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; + +impl DriveHighLevelBatchOperationConverter for TokenPlaceAskTransitionAction { + /// Moves the listed tokens from the seller's balance into the listing's escrow and records + /// the listing as an `ask` document of the token market contract, owned and paid for by the + /// seller, who gets its storage back when it closes. + fn into_high_level_batch_drive_operations<'b>( + self, + epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_place_ask_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let token_id = self.token_id(); + let order_id = self.order_id(); + let amount = self.amount(); + let document = self.order_document().clone(); + let storage_flags = + StorageFlags::new_single_epoch(epoch.index, Some(owner_id.to_buffer())); + + Ok(vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }), + TokenOperation(TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id: owner_id, + order_id, + amount, + }), + DocumentOperation(DocumentOperationType::AddDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + document, + Some(Cow::Owned(storage_flags)), + )), + owner_id: Some(owner_id.into_buffer()), + }, + contract_info: DataContractInfo::DataContractFetchInfo( + self.token_market_contract().clone(), + ), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ), + override_document: false, + }), + ]) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs index c63d0d0326b..988ac576194 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs @@ -23,6 +23,10 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use crate::state_transition_action::batch::batched_transition::token_transition::token_burn_from_pool_transition_action::TokenBurnFromPoolTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionActionAccessorsV0; +use dpp::tokens::token_order_side::TokenOrderSide; use dpp::shielded::serialized_actions_digest; impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { @@ -92,6 +96,14 @@ impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { platform_version, ) } + TokenTransitionAction::PlaceAskAction(place_ask) => { + place_ask.into_high_level_batch_drive_operations(epoch, owner_id, platform_version) + } + TokenTransitionAction::FillAskAction(fill_ask) => { + fill_ask.into_high_level_batch_drive_operations(epoch, owner_id, platform_version) + } + TokenTransitionAction::CancelOrderAction(cancel_order) => cancel_order + .into_high_level_batch_drive_operations(epoch, owner_id, platform_version), } } } @@ -186,6 +198,22 @@ impl TokenTransitionAction { TokenTransitionAction::DirectPurchaseToPoolAction(action) => { TokenEvent::DirectPurchaseToPool(action.token_count(), action.total_agreed_price()) } + TokenTransitionAction::PlaceAskAction(action) => TokenEvent::OrderPlaced( + action.order_id(), + TokenOrderSide::Ask, + action.amount(), + action.price(), + ), + TokenTransitionAction::FillAskAction(action) => TokenEvent::OrderFilled( + action.order().id, + TokenOrderSide::Ask, + action.order().maker_id, + action.fill_amount(), + action.credits(), + ), + TokenTransitionAction::CancelOrderAction(action) => { + TokenEvent::OrderCancelled(action.order().id, action.order().side) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index 38d4b451267..c0b84901ff5 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -50,6 +50,15 @@ pub mod token_claim_to_pool_transition_action; /// Direct purchase paid into the token shielded pool pub mod token_direct_purchase_to_pool_transition_action; +/// A listing of a tradeable token (the token marketplace) +pub mod token_place_ask_transition_action; + +/// A purchase from an open listing (the token marketplace) +pub mod token_fill_ask_transition_action; + +/// A maker cancelling an open order (the token marketplace) +pub mod token_cancel_order_transition_action; + use derive_more::From; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -91,6 +100,9 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use crate::state_transition_action::batch::batched_transition::token_transition::token_burn_from_pool_transition_action::{TokenBurnFromPoolTransitionAction, TokenBurnFromPoolTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::{TokenClaimToPoolTransitionAction, TokenClaimToPoolTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::{TokenDirectPurchaseToPoolTransitionAction, TokenDirectPurchaseToPoolTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; /// token action #[derive(Debug, Clone, From)] @@ -131,6 +143,12 @@ pub enum TokenTransitionAction { ClaimToPoolAction(TokenClaimToPoolTransitionAction), /// direct purchase paid into the token shielded pool DirectPurchaseToPoolAction(TokenDirectPurchaseToPoolTransitionAction), + /// lists a tradeable token for sale (the token marketplace) + PlaceAskAction(TokenPlaceAskTransitionAction), + /// buys from an open listing (the token marketplace) + FillAskAction(TokenFillAskTransitionAction), + /// cancels an open order (the token marketplace) + CancelOrderAction(TokenCancelOrderTransitionAction), } impl TokenTransitionAction { @@ -155,6 +173,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(action) => action.base(), TokenTransitionAction::ClaimToPoolAction(action) => action.base(), TokenTransitionAction::DirectPurchaseToPoolAction(action) => action.base(), + TokenTransitionAction::PlaceAskAction(action) => action.base(), + TokenTransitionAction::FillAskAction(action) => action.base(), + TokenTransitionAction::CancelOrderAction(action) => action.base(), } } @@ -179,6 +200,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(action) => action.base_owned(), TokenTransitionAction::ClaimToPoolAction(action) => action.base_owned(), TokenTransitionAction::DirectPurchaseToPoolAction(action) => action.base_owned(), + TokenTransitionAction::PlaceAskAction(action) => action.base_owned(), + TokenTransitionAction::FillAskAction(action) => action.base_owned(), + TokenTransitionAction::CancelOrderAction(action) => action.base_owned(), } } @@ -203,6 +227,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(_) => "burnFromPool", TokenTransitionAction::ClaimToPoolAction(_) => "claimToPool", TokenTransitionAction::DirectPurchaseToPoolAction(_) => "directPurchaseToPool", + TokenTransitionAction::PlaceAskAction(_) => "orderPlaced", + TokenTransitionAction::FillAskAction(_) => "orderFilled", + TokenTransitionAction::CancelOrderAction(_) => "orderCancelled", } } @@ -275,6 +302,11 @@ impl TokenTransitionAction { | TokenTransitionAction::BurnFromPoolAction(_) | TokenTransitionAction::ClaimToPoolAction(_) | TokenTransitionAction::DirectPurchaseToPoolAction(_) => Ok(false), + // The marketplace records no token history yet: the open orders themselves are + // documents of the token market contract. + TokenTransitionAction::PlaceAskAction(_) + | TokenTransitionAction::FillAskAction(_) + | TokenTransitionAction::CancelOrderAction(_) => Ok(false), } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs new file mode 100644 index 00000000000..543ea3908d7 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs @@ -0,0 +1,45 @@ +use derive_more::From; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// transformer module for the token cancel order transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token cancel order transition action: a maker cancelling an open order (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenCancelOrderTransitionAction { + /// v0 + V0(TokenCancelOrderTransitionActionV0), +} + +impl TokenCancelOrderTransitionActionAccessorsV0 for TokenCancelOrderTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenCancelOrderTransitionAction::V0(v0) => v0.base, + } + } + + fn order(&self) -> &TokenOrder { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.order, + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs new file mode 100644 index 00000000000..f7df0d454f1 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionActionV0, TokenCancelOrderTransitionAction}; +use dpp::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenCancelOrderTransitionAction { + /// Transforms a borrowed `TokenCancelOrderTransition` into a `TokenCancelOrderTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenCancelOrderTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenCancelOrderTransition::V0(v0) => { + TokenCancelOrderTransitionActionV0::try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs new file mode 100644 index 00000000000..93ec68fe698 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs @@ -0,0 +1,66 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// Token cancel order transition action v0 +#[derive(Debug, Clone)] +pub struct TokenCancelOrderTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The order as it stands before the cancel + pub order: TokenOrder, + /// The token market contract, whose document records the order + pub token_market_contract: Arc, +} + +/// Accessors for `TokenCancelOrderTransitionActionV0` +pub trait TokenCancelOrderTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The order as it stands before the cancel + fn order(&self) -> &TokenOrder; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenCancelOrderTransitionActionAccessorsV0 for TokenCancelOrderTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order(&self) -> &TokenOrder { + &self.order + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..85094064aa7 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs @@ -0,0 +1,149 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::{TokenOrderNotFoundError, TokenOrderNotOwnedError}; +use dpp::consensus::ConsensusError; +use dpp::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::TokenCancelOrderTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::v0::TokenCancelOrderTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::BatchedTransitionAction; +use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; + +impl TokenCancelOrderTransitionActionV0 { + /// Converts a borrowed `TokenCancelOrderTransitionV0` into an action: reads the order, which + /// must be open for this token and belong to the signer, else the nonce is bumped with the + /// error. A cancel is refused for nothing else: a maker always gets back what an order holds, + /// whether the token is paused, the maker frozen or the trade mode switched off. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenCancelOrderTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenCancelOrderTransitionV0 { + base, + order_id, + side, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let mut fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let bump = |errors: Vec, fee_result: FeeResult| { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( + base, + owner_id, + user_fee_increase, + ); + Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + errors, + ), + fee_result, + )) + }; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => return bump(base_action_validation_result.errors, fee_result), + }; + + let token_market_contract = drive.token_market_contract_fetch_info(platform_version)?; + let (order_document, read_cost) = drive.fetch_token_order_document( + &token_market_contract.contract, + *side, + *order_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + + let Some(order_document) = order_document else { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + }; + let order = TokenOrder::from_document(&order_document, *side)?; + if order.token_id != base.token_id() { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + } + if order.maker_id != owner_id { + return bump( + vec![TokenOrderNotOwnedError::new(*order_id, order.maker_id, owner_id).into()], + fee_result, + ); + } + // Offers are placed from the next generation of the marketplace on: until then no offer + // exists, and the read above finds none. + if *side == TokenOrderSide::Bid { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + } + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::CancelOrderAction( + TokenCancelOrderTransitionActionV0 { + base: base_action, + order, + token_market_contract, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs new file mode 100644 index 00000000000..b231f3fc7d3 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs @@ -0,0 +1,66 @@ +use derive_more::From; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// transformer module for the token fill ask transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token fill ask transition action: a purchase from an open listing (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenFillAskTransitionAction { + /// v0 + V0(TokenFillAskTransitionActionV0), +} + +impl TokenFillAskTransitionActionAccessorsV0 for TokenFillAskTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.base, + } + } + + fn order(&self) -> &TokenOrder { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.order, + } + } + + fn fill_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.fill_amount, + } + } + + fn credits(&self) -> Credits { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.credits, + } + } + + fn updated_order_document(&self) -> Option<&Document> { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.updated_order_document.as_ref(), + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs new file mode 100644 index 00000000000..18d32f9a811 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionActionV0, TokenFillAskTransitionAction}; +use dpp::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenFillAskTransitionAction { + /// Transforms a borrowed `TokenFillAskTransition` into a `TokenFillAskTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenFillAskTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenFillAskTransition::V0(v0) => { + TokenFillAskTransitionActionV0::try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs new file mode 100644 index 00000000000..a391f9546ca --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs @@ -0,0 +1,102 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// Token fill ask transition action v0 +#[derive(Debug, Clone)] +pub struct TokenFillAskTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The listing as it stands before the fill + pub order: TokenOrder, + /// The amount the fill takes, in base units + pub fill_amount: TokenAmount, + /// The credits the buyer pays the seller for it + pub credits: Credits, + /// The listing's document after a partial fill, stamped with the block; `None` when the + /// fill takes everything open and the listing closes + pub updated_order_document: Option, + /// The token market contract, whose `ask` document records the listing + pub token_market_contract: Arc, +} + +/// Accessors for `TokenFillAskTransitionActionV0` +pub trait TokenFillAskTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The listing as it stands before the fill + fn order(&self) -> &TokenOrder; + + /// The amount the fill takes + fn fill_amount(&self) -> TokenAmount; + + /// The credits the buyer pays + fn credits(&self) -> Credits; + + /// The listing's document after a partial fill, `None` when the listing closes + fn updated_order_document(&self) -> Option<&Document>; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// What the listing holds after the fill + fn remaining_after(&self) -> TokenAmount { + self.order().remaining.saturating_sub(self.fill_amount()) + } + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenFillAskTransitionActionAccessorsV0 for TokenFillAskTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order(&self) -> &TokenOrder { + &self.order + } + + fn fill_amount(&self) -> TokenAmount { + self.fill_amount + } + + fn credits(&self) -> Credits { + self.credits + } + + fn updated_order_document(&self) -> Option<&Document> { + self.updated_order_document.as_ref() + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..6ee06061cd6 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -0,0 +1,194 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::{ + TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderNotFoundError, + TokenOrderPriceLimitError, TokenOrderSelfFillError, +}; +use dpp::consensus::ConsensusError; +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::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::TokenFillAskTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::v0::TokenFillAskTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::BatchedTransitionAction; +use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; + +impl TokenFillAskTransitionActionV0 { + /// Converts a borrowed `TokenFillAskTransitionV0` into an action: reads the listing, and + /// works out the amount the fill takes and the credits the buyer pays. A listing that is not + /// open for this token, the buyer's own listing, an expired listing, a fill range the + /// listing cannot meet and a cost above the buyer's limit each bump the nonce with their + /// error instead. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenFillAskTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenFillAskTransitionV0 { + base, + order_id, + min_amount, + max_amount, + max_total_price, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let mut fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let bump = |errors: Vec, fee_result: FeeResult| { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( + base, + owner_id, + user_fee_increase, + ); + Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + errors, + ), + fee_result, + )) + }; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => return bump(base_action_validation_result.errors, fee_result), + }; + + let token_market_contract = drive.token_market_contract_fetch_info(platform_version)?; + let (order_document, read_cost) = drive.fetch_token_order_document( + &token_market_contract.contract, + TokenOrderSide::Ask, + *order_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + + let Some(order_document) = order_document else { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, TokenOrderSide::Ask).into()], + fee_result, + ); + }; + let order = TokenOrder::from_document(&order_document, TokenOrderSide::Ask)?; + if order.token_id != base.token_id() { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, TokenOrderSide::Ask).into()], + fee_result, + ); + } + if order.maker_id == owner_id { + return bump( + vec![TokenOrderSelfFillError::new(*order_id, owner_id).into()], + fee_result, + ); + } + if let Some(expires_at) = order.expires_at { + if order.is_expired_at(block_info.time_ms) { + return bump( + vec![ + TokenOrderExpiredError::new(*order_id, expires_at, block_info.time_ms) + .into(), + ], + fee_result, + ); + } + } + let Some(fill_amount) = order.fill_amount(*min_amount, *max_amount) else { + return bump( + vec![TokenOrderFillAmountError::new( + *order_id, + *min_amount, + *max_amount, + order.remaining, + order.min_fill, + ) + .into()], + fee_result, + ); + }; + let decimals = base_action.token_configuration()?.conventions().decimals(); + let credits = order.fill_credits(fill_amount, decimals)?; + if credits > *max_total_price { + return bump( + vec![TokenOrderPriceLimitError::new(*order_id, credits, *max_total_price).into()], + fee_result, + ); + } + + let updated_order_document = if fill_amount < order.remaining { + let filled_order = TokenOrder { + remaining: order.remaining - fill_amount, + ..order.clone() + }; + Some(filled_order.updated_document(&order_document, block_info)?) + } else { + None + }; + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::FillAskAction( + TokenFillAskTransitionActionV0 { + base: base_action, + order, + fill_amount, + credits, + updated_order_document, + token_market_contract, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs new file mode 100644 index 00000000000..92243e33f03 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs @@ -0,0 +1,79 @@ +use derive_more::From; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use std::sync::Arc; + +/// transformer module for the token place ask transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token place ask transition action: a listing of a tradeable token (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenPlaceAskTransitionAction { + /// v0 + V0(TokenPlaceAskTransitionActionV0), +} + +impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.base, + } + } + + fn order_id(&self) -> Identifier { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.order_id, + } + } + + fn amount(&self) -> TokenAmount { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.amount, + } + } + + fn price(&self) -> Credits { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.price, + } + } + + fn min_fill(&self) -> TokenAmount { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.min_fill, + } + } + + fn expires_at(&self) -> Option { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.expires_at, + } + } + + fn order_document(&self) -> &Document { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.order_document, + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs new file mode 100644 index 00000000000..1393d58155b --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionActionV0, TokenPlaceAskTransitionAction}; +use dpp::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenPlaceAskTransitionAction { + /// Transforms a borrowed `TokenPlaceAskTransition` into a `TokenPlaceAskTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenPlaceAskTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenPlaceAskTransition::V0(v0) => { + TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs new file mode 100644 index 00000000000..57f8aafd032 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs @@ -0,0 +1,114 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use std::sync::Arc; + +/// Token place ask transition action v0 +#[derive(Debug, Clone)] +pub struct TokenPlaceAskTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The listing's order id, derived from the token, the seller and the nonce + pub order_id: Identifier, + /// The amount listed, in base units + pub amount: TokenAmount, + /// The credits asked per whole token + pub price: Credits, + /// The smallest fill the seller accepts unless it takes everything open + pub min_fill: TokenAmount, + /// The block time from which the listing can no longer be filled + pub expires_at: Option, + /// The listing's `ask` document, stamped with the block that places it + pub order_document: Document, + /// The token market contract, whose `ask` document records the listing + pub token_market_contract: Arc, +} + +/// Accessors for `TokenPlaceAskTransitionActionV0` +pub trait TokenPlaceAskTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The listing's order id + fn order_id(&self) -> Identifier; + + /// The amount listed + fn amount(&self) -> TokenAmount; + + /// The credits asked per whole token + fn price(&self) -> Credits; + + /// The smallest fill the seller accepts + fn min_fill(&self) -> TokenAmount; + + /// The block time from which the listing can no longer be filled + fn expires_at(&self) -> Option; + + /// The listing's `ask` document + fn order_document(&self) -> &Document; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order_id(&self) -> Identifier { + self.order_id + } + + fn amount(&self) -> TokenAmount { + self.amount + } + + fn price(&self) -> Credits { + self.price + } + + fn min_fill(&self) -> TokenAmount { + self.min_fill + } + + fn expires_at(&self) -> Option { + self.expires_at + } + + fn order_document(&self) -> &Document { + &self.order_document + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..79295a156cd --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::v0::TokenPlaceAskTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::BatchedTransitionAction; +use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; + +impl TokenPlaceAskTransitionActionV0 { + /// Converts a borrowed `TokenPlaceAskTransitionV0` into an action. The listing's order id + /// follows from the token, the seller and the nonce; everything the listing needs from state + /// (the token's rules, the seller's balance) is checked by the state validation. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenPlaceAskTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenPlaceAskTransitionV0 { + base, + amount, + price, + min_fill, + expires_at, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( + base, + owner_id, + user_fee_increase, + ); + return Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + base_action_validation_result.errors, + ), + fee_result, + )); + } + }; + + let order_id = + TokenOrderSide::Ask.order_id(base.token_id(), owner_id, base.identity_contract_nonce()); + let order_document = TokenOrder { + id: order_id, + side: TokenOrderSide::Ask, + maker_id: owner_id, + token_id: base.token_id(), + price: *price, + amount: *amount, + remaining: *amount, + min_fill: *min_fill, + expires_at: *expires_at, + escrow: 0, + } + .new_document(block_info)?; + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::PlaceAskAction( + TokenPlaceAskTransitionActionV0 { + base: base_action, + order_id, + amount: *amount, + price: *price, + min_fill: *min_fill, + expires_at: *expires_at, + order_document, + token_market_contract: drive + .token_market_contract_fetch_info(platform_version)?, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs index d0de2956eb0..a090d0135c2 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs @@ -36,6 +36,9 @@ impl TokenTransitionActionTypeGetter for TokenTransitionAction { TokenTransitionAction::DirectPurchaseToPoolAction(_) => { TokenTransitionActionType::DirectPurchaseToPool } + TokenTransitionAction::PlaceAskAction(_) => TokenTransitionActionType::PlaceAsk, + TokenTransitionAction::FillAskAction(_) => TokenTransitionActionType::FillAsk, + TokenTransitionAction::CancelOrderAction(_) => TokenTransitionActionType::CancelOrder, } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs index 6ef507c1f80..c46ffec645d 100644 --- a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs @@ -24,6 +24,7 @@ use crate::state_transition_action::batch::batched_transition::document_transiti use crate::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_transition_action::TokenDirectPurchaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; /// action v0 @@ -242,6 +243,13 @@ impl BatchTransitionActionV0 { // actions, so the fold sees the same sequence of prices there. TokenTransitionAction::DirectPurchaseToPoolAction(token_purchase), ) => Some(token_purchase.total_agreed_price()), + BatchedTransitionAction::TokenAction( + // The credits a buyer pays the seller of a listing. Like the + // purchase-into-pool kind above, a fill is refused unpaid at the batch's + // `is_allowed` gate before protocol version 15, so earlier versions see the + // same sequence of prices. + TokenTransitionAction::FillAskAction(fill_ask), + ) => Some(fill_ask.credits()), _ => None, }) .fold((None, false), |(acc, _), price| match acc { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 3621d5570ca..b736be0c45b 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -531,7 +531,8 @@ impl DriveOperation<'_> { } /// Refuses a batch whose token operations write one identity's balance of a token, one - /// token's total supply, or the total one token's shielded pool holds, more than once. + /// token's total supply, the total one token's shielded pool holds, or what one marketplace + /// listing holds in escrow, more than once. /// /// These writes compute the new value from the one committed before the batch too, but a /// transfer writes two balances, a mint or a burn a balance and the supply, and a shield @@ -563,12 +564,14 @@ enum BalanceKey { } /// A key a token operation writes: an identity's balance of a token, a token's total supply, -/// or the total a token's shielded pool holds +/// the total a token's shielded pool holds, or what a token marketplace listing holds in escrow #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum TokenBalanceKey { Holder(Identifier, Identifier), Supply(Identifier), PoolTotal(Identifier), + /// What one listing of the token marketplace holds in escrow (token, order) + OrderEscrow(Identifier, Identifier), } /// The keys `operation` writes if it is a token operation that moves tokens @@ -634,6 +637,27 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { TokenBalanceKey::Supply(*token_id), TokenBalanceKey::PoolTotal(*token_id), ], + // Listing moves tokens from the seller's balance into the listing's escrow, and a fill or + // a cancel moves them out of it into a balance. Neither moves the total supply: the + // escrow is a term of the conservation sum beside the balances. + TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id, + order_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *holder_id), + TokenBalanceKey::OrderEscrow(*token_id, *order_id), + ], + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id, + order_id, + recipient_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *recipient_id), + TokenBalanceKey::OrderEscrow(*token_id, *order_id), + ], // A transfer inside the pool moves nothing in or out of it, so it leaves the pool // total where it stands and only records nullifiers and appends notes. TokenOperationType::TokenShieldedTransfer { .. } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index f21df7c710a..4af78db4d16 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -191,6 +191,35 @@ pub enum TokenOperationType { /// The change notes of the bundle notes: Vec, }, + /// Moves `amount` of a token from `holder_id`'s balance into the escrow of the new listing + /// `order_id` (the token marketplace, protocol version 15). The supply is unchanged: the + /// escrow is a term of the token conservation sum beside the balances. + TokenPlaceInOrderEscrow { + /// The token id + token_id: Identifier, + /// The seller, whose balance the listed tokens leave + holder_id: Identifier, + /// The listing's order id + order_id: Identifier, + /// The amount listed + amount: TokenAmount, + }, + /// Moves `amount` of a token out of the escrow of the listing `order_id` into `recipient_id`'s + /// balance: the buyer of a fill, or the seller when the listing is cancelled (the token + /// marketplace, protocol version 15). `remaining_after` is what the escrow holds afterwards; + /// at 0 its entry is removed. + TokenReleaseFromOrderEscrow { + /// The token id + token_id: Identifier, + /// The listing's order id + order_id: Identifier, + /// Who receives the tokens + recipient_id: Identifier, + /// The amount leaving escrow + amount: TokenAmount, + /// What the escrow holds after the release + remaining_after: TokenAmount, + }, } impl DriveLowLevelOperationConverter for TokenOperationType { @@ -205,6 +234,63 @@ impl DriveLowLevelOperationConverter for TokenOperationType { platform_version: &PlatformVersion, ) -> Result, Error> { match self { + TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id, + order_id, + amount, + } => { + let mut batch_operations = drive.remove_from_identity_token_balance_operations( + token_id.to_buffer(), + holder_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(drive.add_to_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + Ok(batch_operations) + } + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id, + order_id, + recipient_id, + amount, + remaining_after, + } => { + let mut batch_operations = drive.add_to_identity_token_balance_operations( + token_id.to_buffer(), + recipient_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + if remaining_after > 0 { + batch_operations.extend(drive.set_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + remaining_after, + estimated_costs_only_with_layer_info, + platform_version, + )?); + } else { + batch_operations.extend(drive.remove_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + estimated_costs_only_with_layer_info, + platform_version, + )?); + } + Ok(batch_operations) + } TokenOperationType::TokenBurn { token_id, identity_balance_holder_id, diff --git a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs index 71fdc3d7cf0..c5b839a541e 100644 --- a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs +++ b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs @@ -18,6 +18,7 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio }; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_burn_from_pool_transition::v0::v0_methods::TokenBurnFromPoolTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; use dpp::state_transition::batch_transition::token_freeze_transition::v0::v0_methods::TokenFreezeTransitionV0Methods; use dpp::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; use dpp::state_transition::batch_transition::token_shielded_transfer_transition::v0::v0_methods::TokenShieldedTransferTransitionV0Methods; @@ -25,6 +26,7 @@ use dpp::state_transition::batch_transition::token_transfer_transition::v0::v0_m use dpp::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; use dpp::state_transition::batch_transition::token_unshield_transition::v0::v0_methods::TokenUnshieldTransitionV0Methods; use dpp::system_data_contracts::load_system_data_contract; +use dpp::tokens::token_order_side::TokenOrderSide; use grovedb::PathQuery; use platform_version::version::PlatformVersion; @@ -50,6 +52,24 @@ fn create_token_historical_document_query( query.construct_path_query(platform_version) } +/// The path query of one token market contract order document. +fn create_token_order_document_query( + side: TokenOrderSide, + order_id: Identifier, + platform_version: &PlatformVersion, +) -> Result { + let query = SingleDocumentDriveQuery { + contract_id: SystemDataContract::TokenMarket.id().into_buffer(), + document_type_name: side.document_type_name().to_string(), + document_type_keeps_history: false, + document_id: order_id.to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + }; + + query.construct_path_query(platform_version) +} + fn create_token_group_action_query( contract_id: [u8; 32], identity_id: [u8; 32], @@ -200,6 +220,29 @@ impl TryTransitionIntoPathQuery for TokenTransition { .collect(); token_shielded_pool_nullifiers_path_query(token_id.to_buffer(), &nullifiers) } + // The token marketplace keeps no token history: a listing is attested by its `ask` + // document, a cancel by the order document no longer existing, and a fill by the + // buyer's balance. + TokenTransition::PlaceAsk(_) => create_token_order_document_query( + TokenOrderSide::Ask, + TokenOrderSide::Ask.order_id( + token_id, + owner_id, + self.base().identity_contract_nonce(), + ), + platform_version, + )?, + TokenTransition::CancelOrder(token_cancel_order_transition) => { + create_token_order_document_query( + token_cancel_order_transition.side(), + token_cancel_order_transition.order_id(), + platform_version, + )? + } + TokenTransition::FillAsk(_) => Drive::token_balance_for_identity_id_query( + token_id.to_buffer(), + owner_id.to_buffer(), + ), }; if let Some(group_state_transition_info) = self.base().using_group_info() { 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..02cfc460db9 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 @@ -82,6 +82,10 @@ use platform_version::version::PlatformVersion; use crate::drive::Drive; use crate::drive::identity::key::fetch::IdentityKeysRequest; use crate::verify::RootHash; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; use crate::error::Error; use crate::error::proof::ProofError; use crate::query::{ContractLookupFn, SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; @@ -1331,6 +1335,84 @@ impl Drive { Ok((root_hash, VerifiedShieldedNullifiers(statuses))) } } + // The token marketplace keeps no token history: a listing is proven + // by its `ask` document, whose order id follows from the signer and + // the nonce and whose terms are the transition's. + TokenTransition::PlaceAsk(token_place_ask_transition) => { + let order_id = TokenOrderSide::Ask.order_id( + token_id, + owner_id, + identity_contract_nonce, + ); + let (root_hash, document) = verify_token_order_document( + proof, + TokenOrderSide::Ask, + order_id, + carries_owner_balance, + platform_version, + )?; + let Some(document) = document else { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "proof did not contain the listing {} expected to exist because of state transition (token place ask)", + order_id + )))); + }; + let order = + TokenOrder::from_document(&document, TokenOrderSide::Ask)?; + if order.token_id != token_id + || order.maker_id != owner_id + || order.price != token_place_ask_transition.price() + || order.amount != token_place_ask_transition.amount() + || order.min_fill != token_place_ask_transition.min_fill() + || order.expires_at != token_place_ask_transition.expires_at() + { + return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( + "proof of the listing {} does not hold the terms of state transition (token place ask)", + order_id + )))); + } + Ok(( + root_hash, + VerifiedDocuments(BTreeMap::from([(order_id, Some(document))])), + )) + } + // A cancel is proven by the order no longer existing. + TokenTransition::CancelOrder(token_cancel_order_transition) => { + let order_id = token_cancel_order_transition.order_id(); + let (root_hash, document) = verify_token_order_document( + proof, + token_cancel_order_transition.side(), + order_id, + carries_owner_balance, + platform_version, + )?; + if document.is_some() { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "proof contained the order {} expected to be gone because of state transition (token cancel order)", + order_id + )))); + } + Ok(( + root_hash, + VerifiedDocuments(BTreeMap::from([(order_id, None)])), + )) + } + // A fill is proven by the buyer's balance. + TokenTransition::FillAsk(_) => { + let (root_hash, Some(balance)) = + Drive::verify_token_balance_for_identity_id( + proof, + token_id.into_buffer(), + owner_id.into_buffer(), + carries_owner_balance, + platform_version, + )? + else { + return Err(Error::Proof(ProofError::IncorrectProof( + format!("proof did not contain token balance for identity {} expected to exist because of state transition (token fill ask)", owner_id)))); + }; + Ok((root_hash, VerifiedTokenBalance(owner_id, balance))) + } } } } @@ -3176,6 +3258,12 @@ impl Drive { // not the amount burned: two burns of the same notes leaving different // change spend the same nullifiers. TokenTransition::BurnFromPool(_) => grouped, + // A listing's document carries its id, derived from the signer and the + // nonce, and the transition's terms, which the verification compares. + TokenTransition::PlaceAsk(_) => true, + // A fill leaves a balance snapshot and a cancel an absence, which the + // order being filled entirely would leave too. + TokenTransition::FillAsk(_) | TokenTransition::CancelOrder(_) => false, } } }, @@ -3311,6 +3399,36 @@ impl Drive { /// and verifies to `VerifiedDocuments` with the document named and none. That the type keeps /// none is read from the contract, when the provider holds it: a deletion of a type that keeps /// records is proved by its record without the contract, as it always was. +/// Verifies the proof of one token market contract order document: the document when the order +/// is open, `None` when it is not. +fn verify_token_order_document( + proof: &[u8], + side: TokenOrderSide, + order_id: Identifier, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, +) -> Result<(RootHash, Option), Error> { + let token_market_contract = + load_system_data_contract(SystemDataContract::TokenMarket, platform_version)?; + let document_type = token_market_contract + .document_type_for_name(side.document_type_name()) + .map_err(|e| Error::Proof(ProofError::UnknownContract(e.to_string())))?; + let query = SingleDocumentDriveQuery { + contract_id: token_market_contract.id().into_buffer(), + document_type_name: side.document_type_name().to_string(), + document_type_keeps_history: false, + document_id: order_id.to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + }; + query.verify_proof( + verify_subset_of_proof, + proof, + document_type, + platform_version, + ) +} + fn verify_contract_document_deletion_execution( proof: &[u8], transition: &ContractUserModerationTransition, 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 8af14768f5e..2688c1feb41 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 @@ -315,6 +315,14 @@ pub struct DriveAbciDocumentsStateTransitionValidationVersions { pub token_unshield_transition_state_validation: FeatureVersion, pub token_shielded_transfer_transition_structure_validation: FeatureVersion, pub token_shielded_transfer_transition_state_validation: FeatureVersion, + /// The token marketplace transitions (protocol version 15), refused before their + /// activation by the `is_allowed` gate, so these are 0 in every table. + pub token_place_ask_transition_structure_validation: FeatureVersion, + pub token_place_ask_transition_state_validation: FeatureVersion, + pub token_fill_ask_transition_structure_validation: FeatureVersion, + pub token_fill_ask_transition_state_validation: FeatureVersion, + pub token_cancel_order_transition_structure_validation: FeatureVersion, + pub token_cancel_order_transition_state_validation: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index a4d3f23fd29..3f042058fd9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { 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 23fed710b80..1898c58520c 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 @@ -307,6 +307,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 432bbaa06ec..51a48556a2e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index a568addcbc8..5de1c47d0a0 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index f5c45d3f19c..e3172301496 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -209,6 +209,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index dbd541308a2..59a082f182f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -210,6 +210,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index a1b57e3297e..b419fc5c969 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -213,6 +213,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index cf6c289f4ed..8c3300fbbbb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -207,6 +207,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 633dc8c016f..a060b9147cd 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -261,6 +261,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index b31dea6dff2..77add4ff73b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -257,6 +257,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index dc960993fdf..1e750a5da6c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -69,6 +69,9 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub token_burn_from_pool_transition: FeatureVersion, pub token_claim_to_pool_transition: FeatureVersion, pub token_direct_purchase_to_pool_transition: FeatureVersion, + pub token_place_ask_transition: FeatureVersion, + pub token_fill_ask_transition: FeatureVersion, + pub token_cancel_order_transition: FeatureVersion, pub identity_credit_transfer_to_addresses_transition: FeatureVersion, pub address_funds_transfer_transition: FeatureVersion, pub address_credit_withdrawal_transition: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index 255f358114f..1d348d988ab 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -57,6 +57,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index 352cae7e299..bd2d1044013 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -58,6 +58,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 864760ebb28..c4fb492728c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -62,6 +62,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index 2b3e1d20f6c..4cd92e3735f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -62,6 +62,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index dbe8d3fd120..802d1ef7296 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -149,6 +149,15 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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. +/// +/// 7. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// of a tradeable token at a price in credits per whole token and moves it into escrow), +/// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the +/// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds +/// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, +/// as software that cannot decode them refuses them. Their structure and state validations +/// and their high-level operation converters are 0 in every table, which only this version +/// reaches. 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 diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index d97da1c9594..28c2f5d8ecd 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -34,6 +34,9 @@ use dpp::state_transition::batch_transition::document_base_transition::v2::v2_me use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_burn_transition::v0::v0_methods::TokenBurnTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; use dpp::state_transition::batch_transition::token_claim_transition::v0::v0_methods::TokenClaimTransitionV0Methods; use dpp::state_transition::batch_transition::token_config_update_transition::v0::v0_methods::TokenConfigUpdateTransitionV0Methods; use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::v0_methods::TokenDestroyFrozenFundsTransitionV0Methods; @@ -153,14 +156,15 @@ pub struct BatchedTransitionSummary { pub action: String, pub target: BatchedTransitionTarget, /// Credits or tokens moved: a document purchase price or update-price - /// value, a token transfer / mint / burn amount, or a direct purchase's - /// total agreed price. + /// value, a token transfer / mint / burn amount, a direct purchase's + /// total agreed price, the tokens a listing places in escrow, or the most + /// credits a listing fill spends. pub amount: Option, /// The identity on the other side, read against `action`: a document /// transfer's new owner, a token transfer's recipient, a mint's issued-to /// identity, or the frozen identity of a freeze / unfreeze / destroy. pub recipient_id: Option, - /// Tokens bought by a direct purchase. + /// Tokens bought by a direct purchase, or the most a listing fill buys. pub token_count: Option, /// The material fields `amount` / `recipient_id` / `token_count` do not /// cover, one per line (document data, a config change item, a price @@ -593,6 +597,25 @@ fn summarize_token_transition( } (None, None, None, t.public_note()) } + // A listing moves `amount` tokens into escrow; its price and terms are details. + TokenTransition::PlaceAsk(t) => { + lines.push(budget.write(format_args!("price: {} credits per whole token", t.price()))?); + lines.push(budget.write(format_args!("minimum fill: {}", t.min_fill()))?); + if let Some(expires_at) = t.expires_at() { + lines.push(budget.write(format_args!("expires at: {expires_at} ms"))?); + } + (Some(t.amount()), None, None, None) + } + // A fill spends at most `max_total_price` credits for at most `max_amount` tokens. + TokenTransition::FillAsk(t) => { + lines.push(budget.write(format_args!("listing: {}", t.order_id()))?); + lines.push(budget.write(format_args!("minimum amount: {}", t.min_amount()))?); + (Some(t.max_total_price()), None, Some(t.max_amount()), None) + } + TokenTransition::CancelOrder(t) => { + lines.push(budget.write(format_args!("order: {} ({})", t.order_id(), t.side()))?); + (None, None, None, None) + } // Shielded-pool operations carry notes, proofs and amounts with no typed projection: the // whole transition is rendered and the row is incomplete. TokenTransition::Shield(_) diff --git a/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs b/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs index 9c999f1860b..dcb9d9de7d5 100644 --- a/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs +++ b/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs @@ -51,6 +51,9 @@ pub enum TokenTransitionType { BurnFromPool, ClaimToPool, DirectPurchaseToPool, + PlaceAsk, + FillAsk, + CancelOrder, } impl From<&TokenTransition> for TokenTransitionType { @@ -76,6 +79,9 @@ impl From<&TokenTransition> for TokenTransitionType { TokenTransition::BurnFromPool(_) => TokenTransitionType::BurnFromPool, TokenTransition::ClaimToPool(_) => TokenTransitionType::ClaimToPool, TokenTransition::DirectPurchaseToPool(_) => TokenTransitionType::DirectPurchaseToPool, + TokenTransition::PlaceAsk(_) => TokenTransitionType::PlaceAsk, + TokenTransition::FillAsk(_) => TokenTransitionType::FillAsk, + TokenTransition::CancelOrder(_) => TokenTransitionType::CancelOrder, } } } @@ -176,6 +182,12 @@ impl TokenTransitionWasm { | TokenTransition::DirectPurchaseToPool(_) => Err(JsValue::from_str( "shielded and pool-backed token transitions are not supported in wasm-dpp TokenTransition; use wasm-dpp2", )), + // The token marketplace kinds likewise: wasm-dpp2 is the binding that exposes them. + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => Err(JsValue::from_str( + "token marketplace transitions are not supported in wasm-dpp TokenTransition; use wasm-dpp2", + )), } } } diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 9615e50e313..3d2d20fec2d 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -6,6 +6,12 @@ use crate::errors::consensus::basic::{ InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, UnsupportedProtocolVersionErrorWasm, UnsupportedVersionErrorWasm, }; +use dpp::consensus::basic::token::{InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError}; +use dpp::consensus::state::token::{ + TokenNotTradeableError, TokenOrderExpiredError, TokenOrderFillAmountError, + TokenOrderNotFoundError, TokenOrderNotOwnedError, TokenOrderPriceLimitError, + TokenOrderSelfFillError, TokenOrderValueTooLargeError, +}; use dpp::consensus::ConsensusError as DPPConsensusError; use crate::errors::consensus::basic::identity::{ @@ -527,6 +533,30 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenNotTransferableError(e) => { generic_consensus_error!(TokenNotTransferableError, e).into() } + StateError::TokenNotTradeableError(e) => { + generic_consensus_error!(TokenNotTradeableError, e).into() + } + StateError::TokenOrderNotFoundError(e) => { + generic_consensus_error!(TokenOrderNotFoundError, e).into() + } + StateError::TokenOrderExpiredError(e) => { + generic_consensus_error!(TokenOrderExpiredError, e).into() + } + StateError::TokenOrderFillAmountError(e) => { + generic_consensus_error!(TokenOrderFillAmountError, e).into() + } + StateError::TokenOrderPriceLimitError(e) => { + generic_consensus_error!(TokenOrderPriceLimitError, e).into() + } + StateError::TokenOrderNotOwnedError(e) => { + generic_consensus_error!(TokenOrderNotOwnedError, e).into() + } + StateError::TokenOrderSelfFillError(e) => { + generic_consensus_error!(TokenOrderSelfFillError, e).into() + } + StateError::TokenOrderValueTooLargeError(e) => { + generic_consensus_error!(TokenOrderValueTooLargeError, e).into() + } StateError::IdentityInTokenConfigurationNotFoundError(e) => { generic_consensus_error!(IdentityInTokenConfigurationNotFoundError, e).into() } @@ -1424,6 +1454,12 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::NonTransferableTokenTradeableError(e) => { generic_consensus_error!(NonTransferableTokenTradeableError, e).into() } + BasicError::InvalidTokenOrderPriceError(e) => { + generic_consensus_error!(InvalidTokenOrderPriceError, e).into() + } + BasicError::InvalidTokenOrderFillRangeError(e) => { + generic_consensus_error!(InvalidTokenOrderFillRangeError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/group/token_event.rs b/packages/wasm-dpp2/src/group/token_event.rs index 0058ef5e6f0..f93af124f12 100644 --- a/packages/wasm-dpp2/src/group/token_event.rs +++ b/packages/wasm-dpp2/src/group/token_event.rs @@ -36,7 +36,11 @@ const TS_TYPES: &str = r#" * - BurnFromPool: { $type: "burnFromPool", amount, actionsDigest, publicNote } * - ClaimToPool: { $type: "claimToPool", amount } * - DirectPurchaseToPool: { $type: "directPurchaseToPool", amount, credits } + * - OrderPlaced: { $type: "orderPlaced", orderId, side, amount, price } + * - OrderFilled: { $type: "orderFilled", orderId, side, maker, amount, credits } + * - OrderCancelled: { $type: "orderCancelled", orderId, side } * + * `side` is `"ask"` (a listing) or `"bid"` (an offer). * `amount`/`credits` are routed through json_safe_u64 — small numbers, JS * BigInt-safe stringification above 2^53. Identifier fields use base58 in * JSON, Uint8Array in toObject(). @@ -87,6 +91,9 @@ pub enum TokenEventVariant { BurnFromPool = 15, ClaimToPool = 16, DirectPurchaseToPool = 17, + OrderPlaced = 18, + OrderFilled = 19, + OrderCancelled = 20, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -131,6 +138,9 @@ impl TokenEventWasm { TokenEvent::BurnFromPool(..) => TokenEventVariant::BurnFromPool, TokenEvent::ClaimToPool(..) => TokenEventVariant::ClaimToPool, TokenEvent::DirectPurchaseToPool(..) => TokenEventVariant::DirectPurchaseToPool, + TokenEvent::OrderPlaced(..) => TokenEventVariant::OrderPlaced, + TokenEvent::OrderFilled(..) => TokenEventVariant::OrderFilled, + TokenEvent::OrderCancelled(..) => TokenEventVariant::OrderCancelled, } } } @@ -152,6 +162,7 @@ mod tests { use dpp::identifier::Identifier; use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_event::TokenEvent; + use dpp::tokens::token_order_side::TokenOrderSide; /// `TokenEventObject` is an index signature, so the per-variant field names /// a TypeScript consumer works from live only in the doc block's prose and @@ -243,6 +254,9 @@ mod tests { TokenEvent::BurnFromPool(..) => "BurnFromPool", TokenEvent::ClaimToPool(..) => "ClaimToPool", TokenEvent::DirectPurchaseToPool(..) => "DirectPurchaseToPool", + TokenEvent::OrderPlaced(..) => "OrderPlaced", + TokenEvent::OrderFilled(..) => "OrderFilled", + TokenEvent::OrderCancelled(..) => "OrderCancelled", } } @@ -276,6 +290,9 @@ mod tests { TokenEvent::BurnFromPool(1, id, None), TokenEvent::ClaimToPool(1), TokenEvent::DirectPurchaseToPool(1, 1), + TokenEvent::OrderPlaced(id, TokenOrderSide::Ask, 1, 1), + TokenEvent::OrderFilled(id, TokenOrderSide::Ask, id, 1, 1), + TokenEvent::OrderCancelled(id, TokenOrderSide::Bid), ] } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs index 500b38224f2..4d29dd17314 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs @@ -8,14 +8,17 @@ use crate::state_transitions::batch::token_transitions::direct_purchase::TokenDi use crate::state_transitions::batch::token_transitions::set_price_for_direct_purchase::TokenSetPriceForDirectPurchaseTransitionWasm; use crate::state_transitions::batch::token_transitions::token_burn::TokenBurnTransitionWasm; use crate::state_transitions::batch::token_transitions::token_burn_from_pool::TokenBurnFromPoolTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_cancel_order::TokenCancelOrderTransitionWasm; use crate::state_transitions::batch::token_transitions::token_claim::TokenClaimTransitionWasm; use crate::state_transitions::batch::token_transitions::token_claim_to_pool::TokenClaimToPoolTransitionWasm; use crate::state_transitions::batch::token_transitions::token_destroy_frozen_funds::TokenDestroyFrozenFundsTransitionWasm; use crate::state_transitions::batch::token_transitions::token_direct_purchase_to_pool::TokenDirectPurchaseToPoolTransitionWasm; use crate::state_transitions::batch::token_transitions::token_emergency_action::TokenEmergencyActionTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_fill_ask::TokenFillAskTransitionWasm; use crate::state_transitions::batch::token_transitions::token_freeze::TokenFreezeTransitionWasm; use crate::state_transitions::batch::token_transitions::token_mint::TokenMintTransitionWasm; use crate::state_transitions::batch::token_transitions::token_mint_to_pool::TokenMintToPoolTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_place_ask::TokenPlaceAskTransitionWasm; use crate::state_transitions::batch::token_transitions::token_shield::TokenShieldTransitionWasm; use crate::state_transitions::batch::token_transitions::token_shielded_transfer::TokenShieldedTransferTransitionWasm; use crate::state_transitions::batch::token_transitions::token_transfer::TokenTransferTransitionWasm; @@ -27,11 +30,12 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio TokenTransition, TokenTransitionV0Methods, }; use dpp::state_transition::batch_transition::{ - TokenBurnFromPoolTransition, TokenBurnTransition, TokenClaimToPoolTransition, - TokenClaimTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, - TokenDirectPurchaseToPoolTransition, TokenDirectPurchaseTransition, - TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintToPoolTransition, - TokenMintTransition, TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, + TokenBurnFromPoolTransition, TokenBurnTransition, TokenCancelOrderTransition, + TokenClaimToPoolTransition, TokenClaimTransition, TokenConfigUpdateTransition, + TokenDestroyFrozenFundsTransition, TokenDirectPurchaseToPoolTransition, + TokenDirectPurchaseTransition, TokenEmergencyActionTransition, TokenFillAskTransition, + TokenFreezeTransition, TokenMintToPoolTransition, TokenMintTransition, TokenPlaceAskTransition, + TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, TokenShieldedTransferTransition, TokenTransferTransition, TokenUnfreezeTransition, TokenUnshieldTransition, }; @@ -40,7 +44,7 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const TOKEN_TRANSITION_TYPES_TS: &str = r#" -export type TokenTransitionLike = TokenMintTransition | TokenBurnTransition | TokenTransferTransition | TokenFreezeTransition | TokenUnFreezeTransition | TokenDestroyFrozenFundsTransition | TokenClaimTransition | TokenEmergencyActionTransition | TokenConfigUpdateTransition | TokenDirectPurchaseTransition | TokenSetPriceForDirectPurchaseTransition | TokenShieldTransition | TokenUnshieldTransition | TokenShieldedTransferTransition | TokenMintToPoolTransition | TokenBurnFromPoolTransition | TokenClaimToPoolTransition | TokenDirectPurchaseToPoolTransition; +export type TokenTransitionLike = TokenMintTransition | TokenBurnTransition | TokenTransferTransition | TokenFreezeTransition | TokenUnFreezeTransition | TokenDestroyFrozenFundsTransition | TokenClaimTransition | TokenEmergencyActionTransition | TokenConfigUpdateTransition | TokenDirectPurchaseTransition | TokenSetPriceForDirectPurchaseTransition | TokenShieldTransition | TokenUnshieldTransition | TokenShieldedTransferTransition | TokenMintToPoolTransition | TokenBurnFromPoolTransition | TokenClaimToPoolTransition | TokenDirectPurchaseToPoolTransition | TokenPlaceAskTransition | TokenFillAskTransition | TokenCancelOrderTransition; "#; /// Extern type for flexible TokenTransition input @@ -70,6 +74,9 @@ impl_from_for_extern_type!( TokenBurnFromPoolTransitionWasm, TokenClaimToPoolTransitionWasm, TokenDirectPurchaseToPoolTransitionWasm, + TokenPlaceAskTransitionWasm, + TokenFillAskTransitionWasm, + TokenCancelOrderTransitionWasm, ); #[derive(Debug, Clone, PartialEq)] @@ -170,6 +177,17 @@ impl TokenTransitionWasm { TokenDirectPurchaseToPoolTransitionWasm::try_from(&transition)?, )) } + "TokenPlaceAskTransition" => TokenTransition::from(TokenPlaceAskTransition::from( + TokenPlaceAskTransitionWasm::try_from(&transition)?, + )), + "TokenFillAskTransition" => TokenTransition::from(TokenFillAskTransition::from( + TokenFillAskTransitionWasm::try_from(&transition)?, + )), + "TokenCancelOrderTransition" => { + TokenTransition::from(TokenCancelOrderTransition::from( + TokenCancelOrderTransitionWasm::try_from(&transition)?, + )) + } _ => { return Err(WasmDppError::invalid_argument("Bad token transition input")); } @@ -235,6 +253,15 @@ impl TokenTransitionWasm { TokenTransition::DirectPurchaseToPool(token_transition) => { TokenDirectPurchaseToPoolTransitionWasm::from(token_transition).into() } + TokenTransition::PlaceAsk(token_transition) => { + TokenPlaceAskTransitionWasm::from(token_transition).into() + } + TokenTransition::FillAsk(token_transition) => { + TokenFillAskTransitionWasm::from(token_transition).into() + } + TokenTransition::CancelOrder(token_transition) => { + TokenCancelOrderTransitionWasm::from(token_transition).into() + } } } @@ -259,6 +286,9 @@ impl TokenTransitionWasm { TokenTransition::BurnFromPool(_) => 15, TokenTransition::ClaimToPool(_) => 16, TokenTransition::DirectPurchaseToPool(_) => 17, + TokenTransition::PlaceAsk(_) => 18, + TokenTransition::FillAsk(_) => 19, + TokenTransition::CancelOrder(_) => 20, } } @@ -285,6 +315,9 @@ impl TokenTransitionWasm { TokenTransition::BurnFromPool(_) => "BurnFromPool".to_string(), TokenTransition::ClaimToPool(_) => "ClaimToPool".to_string(), TokenTransition::DirectPurchaseToPool(_) => "DirectPurchaseToPool".to_string(), + TokenTransition::PlaceAsk(_) => "PlaceAsk".to_string(), + TokenTransition::FillAsk(_) => "FillAsk".to_string(), + TokenTransition::CancelOrder(_) => "CancelOrder".to_string(), } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs index 8127328dc8b..e66ab8495fe 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs @@ -3,14 +3,17 @@ pub mod direct_purchase; pub mod set_price_for_direct_purchase; pub mod token_burn; pub mod token_burn_from_pool; +pub mod token_cancel_order; pub mod token_claim; pub mod token_claim_to_pool; pub mod token_destroy_frozen_funds; pub mod token_direct_purchase_to_pool; pub mod token_emergency_action; +pub mod token_fill_ask; pub mod token_freeze; pub mod token_mint; pub mod token_mint_to_pool; +pub mod token_place_ask; pub mod token_shield; pub mod token_shielded_transfer; pub mod token_transfer; diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs new file mode 100644 index 00000000000..d3f63e08211 --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs @@ -0,0 +1,130 @@ +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_with, try_to_string}; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; +use dpp::state_transition::batch_transition::TokenCancelOrderTransition; +use dpp::tokens::token_order_side::TokenOrderSide; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_CANCEL_ORDER_OPTIONS_TS: &str = r#" +/** The side of a token marketplace order: a listing (`ask`) or an offer (`bid`). */ +export type TokenOrderSide = "ask" | "bid"; + +/** Cancels the sender's open order `orderId` and returns what it holds in escrow. */ +export interface TokenCancelOrderTransitionOptions { + base: TokenBaseTransition; + orderId: IdentifierLike; + side: TokenOrderSide; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenCancelOrderTransitionOptions")] + pub type TokenCancelOrderTransitionOptionsJs; +} + +fn order_side_from_str(side: &str) -> WasmDppResult { + match side { + "ask" => Ok(TokenOrderSide::Ask), + "bid" => Ok(TokenOrderSide::Bid), + other => Err(WasmDppError::invalid_argument(format!( + "'side' must be \"ask\" or \"bid\", got \"{}\"", + other + ))), + } +} + +fn order_side_to_str(side: TokenOrderSide) -> &'static str { + match side { + TokenOrderSide::Ask => "ask", + TokenOrderSide::Bid => "bid", + } +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenCancelOrderTransition")] +pub struct TokenCancelOrderTransitionWasm(TokenCancelOrderTransition); + +impl From for TokenCancelOrderTransition { + fn from(transition: TokenCancelOrderTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenCancelOrderTransitionWasm { + fn from(transition: TokenCancelOrderTransition) -> Self { + TokenCancelOrderTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenCancelOrderTransition)] +impl TokenCancelOrderTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenCancelOrderTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let order_id: IdentifierWasm = try_from_options(&options, "orderId")?; + + let side = try_from_options_with(&options, "side", |v| { + order_side_from_str(&try_to_string(v, "side")?) + })?; + + Ok(TokenCancelOrderTransitionWasm( + TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0 { + base: base.into(), + order_id: order_id.into(), + side, + }), + )) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = orderId)] + pub fn order_id(&self) -> IdentifierWasm { + self.0.order_id().into() + } + + #[wasm_bindgen(getter = side, unchecked_return_type = "TokenOrderSide")] + pub fn side(&self) -> String { + order_side_to_str(self.0.side()).to_string() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + #[wasm_bindgen(setter = orderId)] + pub fn set_order_id( + &mut self, + #[wasm_bindgen(js_name = "orderId")] order_id: IdentifierLikeJs, + ) -> WasmDppResult<()> { + self.0.set_order_id(order_id.try_into()?); + Ok(()) + } + + #[wasm_bindgen(setter = side)] + pub fn set_side( + &mut self, + #[wasm_bindgen(unchecked_param_type = "TokenOrderSide")] side: String, + ) -> WasmDppResult<()> { + self.0.set_side(order_side_from_str(&side)?); + Ok(()) + } +} + +impl_try_from_js_value!(TokenCancelOrderTransitionWasm, "TokenCancelOrderTransition"); +impl_wasm_type_info!(TokenCancelOrderTransitionWasm, TokenCancelOrderTransition); diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs new file mode 100644 index 00000000000..7c52e6ec07e --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs @@ -0,0 +1,148 @@ +use crate::error::WasmDppResult; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_with, try_to_u64}; +use dpp::balances::credits::TokenAmount; +use dpp::fee::Credits; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; +use dpp::state_transition::batch_transition::TokenFillAskTransition; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_FILL_ASK_OPTIONS_TS: &str = r#" +/** + * Buys from the listing `orderId`: between `minAmount` and `maxAmount` base units, for at most + * `maxTotalPrice` credits in all. The fill takes as much as the range and the listing allow. + */ +export interface TokenFillAskTransitionOptions { + base: TokenBaseTransition; + orderId: IdentifierLike; + minAmount: bigint; + maxAmount: bigint; + maxTotalPrice: bigint; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenFillAskTransitionOptions")] + pub type TokenFillAskTransitionOptionsJs; +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenFillAskTransition")] +pub struct TokenFillAskTransitionWasm(TokenFillAskTransition); + +impl From for TokenFillAskTransition { + fn from(transition: TokenFillAskTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenFillAskTransitionWasm { + fn from(transition: TokenFillAskTransition) -> Self { + TokenFillAskTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenFillAskTransition)] +impl TokenFillAskTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenFillAskTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let order_id: IdentifierWasm = try_from_options(&options, "orderId")?; + + let min_amount: TokenAmount = + try_from_options_with(&options, "minAmount", |v| try_to_u64(v, "minAmount"))?; + + let max_amount: TokenAmount = + try_from_options_with(&options, "maxAmount", |v| try_to_u64(v, "maxAmount"))?; + + let max_total_price: Credits = try_from_options_with(&options, "maxTotalPrice", |v| { + try_to_u64(v, "maxTotalPrice") + })?; + + Ok(TokenFillAskTransitionWasm(TokenFillAskTransition::V0( + TokenFillAskTransitionV0 { + base: base.into(), + order_id: order_id.into(), + min_amount, + max_amount, + max_total_price, + }, + ))) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = orderId)] + pub fn order_id(&self) -> IdentifierWasm { + self.0.order_id().into() + } + + #[wasm_bindgen(getter = minAmount)] + pub fn min_amount(&self) -> TokenAmount { + self.0.min_amount() + } + + #[wasm_bindgen(getter = maxAmount)] + pub fn max_amount(&self) -> TokenAmount { + self.0.max_amount() + } + + #[wasm_bindgen(getter = maxTotalPrice)] + pub fn max_total_price(&self) -> Credits { + self.0.max_total_price() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + #[wasm_bindgen(setter = orderId)] + pub fn set_order_id( + &mut self, + #[wasm_bindgen(js_name = "orderId")] order_id: IdentifierLikeJs, + ) -> WasmDppResult<()> { + self.0.set_order_id(order_id.try_into()?); + Ok(()) + } + + #[wasm_bindgen(setter = minAmount)] + pub fn set_min_amount( + &mut self, + #[wasm_bindgen(js_name = "minAmount")] min_amount: TokenAmount, + ) { + self.0.set_min_amount(min_amount) + } + + #[wasm_bindgen(setter = maxAmount)] + pub fn set_max_amount( + &mut self, + #[wasm_bindgen(js_name = "maxAmount")] max_amount: TokenAmount, + ) { + self.0.set_max_amount(max_amount) + } + + #[wasm_bindgen(setter = maxTotalPrice)] + pub fn set_max_total_price( + &mut self, + #[wasm_bindgen(js_name = "maxTotalPrice")] max_total_price: Credits, + ) { + self.0.set_max_total_price(max_total_price) + } +} + +impl_try_from_js_value!(TokenFillAskTransitionWasm, "TokenFillAskTransition"); +impl_wasm_type_info!(TokenFillAskTransitionWasm, TokenFillAskTransition); diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs new file mode 100644 index 00000000000..174542cd386 --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -0,0 +1,138 @@ +use crate::error::WasmDppResult; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_optional_with, try_from_options_with, try_to_u64}; +use dpp::balances::credits::TokenAmount; +use dpp::fee::Credits; +use dpp::prelude::TimestampMillis; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; +use dpp::state_transition::batch_transition::TokenPlaceAskTransition; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_PLACE_ASK_OPTIONS_TS: &str = r#" +/** + * Lists `amount` of the token for sale at `price` credits per whole token. The tokens move into + * escrow until the listing is filled or cancelled. A fill takes at least `minFill`, unless it + * takes everything left. A listing with `expiresAt` can no longer be filled from that block time. + */ +export interface TokenPlaceAskTransitionOptions { + base: TokenBaseTransition; + amount: bigint; + price: bigint; + minFill: bigint; + expiresAt?: bigint; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenPlaceAskTransitionOptions")] + pub type TokenPlaceAskTransitionOptionsJs; +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenPlaceAskTransition")] +pub struct TokenPlaceAskTransitionWasm(TokenPlaceAskTransition); + +impl From for TokenPlaceAskTransition { + fn from(transition: TokenPlaceAskTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenPlaceAskTransitionWasm { + fn from(transition: TokenPlaceAskTransition) -> Self { + TokenPlaceAskTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenPlaceAskTransition)] +impl TokenPlaceAskTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenPlaceAskTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let amount: TokenAmount = + try_from_options_with(&options, "amount", |v| try_to_u64(v, "amount"))?; + + let price: Credits = try_from_options_with(&options, "price", |v| try_to_u64(v, "price"))?; + + let min_fill: TokenAmount = + try_from_options_with(&options, "minFill", |v| try_to_u64(v, "minFill"))?; + + let expires_at: Option = + try_from_options_optional_with(&options, "expiresAt", |v| try_to_u64(v, "expiresAt"))?; + + Ok(TokenPlaceAskTransitionWasm(TokenPlaceAskTransition::V0( + TokenPlaceAskTransitionV0 { + base: base.into(), + amount, + price, + min_fill, + expires_at, + }, + ))) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = amount)] + pub fn amount(&self) -> TokenAmount { + self.0.amount() + } + + #[wasm_bindgen(getter = price)] + pub fn price(&self) -> Credits { + self.0.price() + } + + #[wasm_bindgen(getter = minFill)] + pub fn min_fill(&self) -> TokenAmount { + self.0.min_fill() + } + + #[wasm_bindgen(getter = expiresAt)] + pub fn expires_at(&self) -> Option { + self.0.expires_at() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + #[wasm_bindgen(setter = amount)] + pub fn set_amount(&mut self, amount: TokenAmount) { + self.0.set_amount(amount) + } + + #[wasm_bindgen(setter = price)] + pub fn set_price(&mut self, price: Credits) { + self.0.set_price(price) + } + + #[wasm_bindgen(setter = minFill)] + pub fn set_min_fill(&mut self, #[wasm_bindgen(js_name = "minFill")] min_fill: TokenAmount) { + self.0.set_min_fill(min_fill) + } + + #[wasm_bindgen(setter = expiresAt)] + pub fn set_expires_at( + &mut self, + #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, + ) { + self.0.set_expires_at(expires_at) + } +} + +impl_try_from_js_value!(TokenPlaceAskTransitionWasm, "TokenPlaceAskTransition"); +impl_wasm_type_info!(TokenPlaceAskTransitionWasm, TokenPlaceAskTransition); From 10c13978772864bfaa6f27addff2eed099d4cf18 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 22:47:34 +0300 Subject: [PATCH 035/121] 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 e31796b1329de49ce0dde19ce894106bcc1c7972 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 21 Oct 2026 22:56:00 +0300 Subject: [PATCH 036/121] refactor(drive-abci): check listings' state-free rules in check_tx A listing's tradeable token, value and expiry, and a fill's tradeable token, read nothing beyond the contract the action carries and the block, so they move from state validation to per-action structure validations 0 that the batch's advanced structure 2 runs, where check_tx runs them too. Paused, frozen and balance checks stay in state validation. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 3 +- .../token_fill_ask_transition_action/mod.rs | 29 ++++++++++ .../state_v0/mod.rs | 15 ++--- .../structure_v0/mod.rs | 25 +++++++++ .../token_place_ask_transition_action/mod.rs | 31 +++++++++++ .../state_v0/mod.rs | 35 ++---------- .../structure_v0/mod.rs | 55 +++++++++++++++++++ .../batch/advanced_structure/v2/mod.rs | 12 +++- .../batch/tests/token/marketplace/mod.rs | 41 ++++++++++++++ .../rs-platform-version/src/version/v15.rs | 8 ++- 10 files changed, 209 insertions(+), 45 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index e24682085aa..4c5b5ae5b8a 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -136,7 +136,8 @@ The rounding favours the seller and is never more than one credit per fill. | The seller's account is not frozen (to place or fill), nor the buyer's unless the token allows transfers to frozen balances | `IdentityTokenAccountFrozenError` | The state rules are paid: a refused transition bumps the signer's nonce and charges the work that -ran. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the +ran. The rules that read nothing beyond the contract and the block (a tradeable token, a listing's +value and its expiry) run with the batch's advanced structure, so check_tx refuses them too. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are checked with the batch's fees, which count a fill's cost like a direct purchase. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs index ed9a31f7057..eee4fad3b3c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs @@ -8,11 +8,20 @@ use crate::error::Error; 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_fill_ask_transition_action::state_v0::TokenFillAskTransitionActionStateValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::structure_v0::TokenFillAskTransitionActionStructureValidationV0; use crate::platform_types::platform::PlatformStateRef; mod state_v0; +mod structure_v0; pub trait TokenFillAskTransitionActionValidation { + /// Checks that need only the contract the action carries and the block, run with the + /// batch's advanced structure so that check_tx refuses a failing transition too. + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; + fn validate_state( &self, platform: &PlatformStateRef, @@ -25,6 +34,26 @@ pub trait TokenFillAskTransitionActionValidation { } impl TokenFillAskTransitionActionValidation for TokenFillAskTransitionAction { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::validate_structure".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + fn validate_state( &self, platform: &PlatformStateRef, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs index 74da60be4b7..45103938d52 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs @@ -6,7 +6,7 @@ use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; -use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::{validate_token_account_not_frozen, validate_token_tradeable}; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_account_not_frozen; use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; @@ -26,10 +26,10 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: } impl TokenFillAskTransitionActionStateValidationV0 for TokenFillAskTransitionAction { - /// The transformer already read the listing and refused a fill it cannot meet. A fill also - /// needs a token that is still tradeable and not paused, a seller whose account is not - /// frozen, and a buyer whose account is not frozen unless the token allows transfers to - /// frozen balances. The buyer's credit balance is checked with the batch's fees, which count + /// The transformer already read the listing and refused a fill it cannot meet, and the + /// batch's advanced structure a token that is no longer tradeable. A fill also needs a token + /// that is not paused, a seller whose account is not frozen, and a buyer whose account is not + /// frozen unless the token allows transfers to frozen balances. The buyer's credit balance is checked with the batch's fees, which count /// the fill's cost as a purchase. fn validate_state_v0( &self, @@ -52,11 +52,6 @@ impl TokenFillAskTransitionActionStateValidationV0 for TokenFillAskTransitionAct return Ok(validation_result); } - let validation_result = validate_token_tradeable(self.base())?; - if !validation_result.is_valid() { - return Ok(validation_result); - } - let validation_result = validate_token_not_paused( platform, self.token_id(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs new file mode 100644 index 00000000000..17696249f74 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs @@ -0,0 +1,25 @@ +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; + +use crate::error::Error; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_tradeable; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait TokenFillAskTransitionActionStructureValidationV0 +{ + fn validate_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionStructureValidationV0 for TokenFillAskTransitionAction { + /// A fill needs a token that is still tradeable: one whose trade mode changed back to + /// `NotTradeable` keeps its open listings, which can be cancelled but no longer filled. + fn validate_structure_v0( + &self, + _platform_version: &PlatformVersion, + ) -> Result { + validate_token_tradeable(self.base()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs index 536c081aa56..7ccf94b1264 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs @@ -8,11 +8,21 @@ use crate::error::Error; 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_place_ask_transition_action::state_v0::TokenPlaceAskTransitionActionStateValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::structure_v0::TokenPlaceAskTransitionActionStructureValidationV0; use crate::platform_types::platform::PlatformStateRef; mod state_v0; +mod structure_v0; pub trait TokenPlaceAskTransitionActionValidation { + /// Checks that need only the contract the action carries and the block, run with the + /// batch's advanced structure so that check_tx refuses a failing transition too. + fn validate_structure( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result; + fn validate_state( &self, platform: &PlatformStateRef, @@ -25,6 +35,27 @@ pub trait TokenPlaceAskTransitionActionValidation { } impl TokenPlaceAskTransitionActionValidation for TokenPlaceAskTransitionAction { + fn validate_structure( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(block_info, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::validate_structure".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + fn validate_state( &self, platform: &PlatformStateRef, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs index f2c48fe7ce3..c6280055c13 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs @@ -5,15 +5,11 @@ use dpp::version::PlatformVersion; use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; use dpp::consensus::state::state_error::StateError; -use dpp::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, TokenOrderExpiredError, TokenOrderValueTooLargeError}; +use dpp::consensus::state::token::IdentityDoesNotHaveEnoughTokenBalanceError; use dpp::consensus::ConsensusError; -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::tokens::token_order::TokenOrder; -use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; -use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::{validate_token_account_not_frozen, validate_token_tradeable}; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_account_not_frozen; use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; @@ -33,9 +29,9 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: } impl TokenPlaceAskTransitionActionStateValidationV0 for TokenPlaceAskTransitionAction { - /// A listing needs a tradeable token that is not paused, a seller whose account is not - /// frozen and who holds the amount, a total that fits in an order (`i64::MAX` credits) and, - /// when it expires, an expiry after the block. + /// A listing needs a token that is not paused and a seller whose account is not frozen and + /// who holds the amount. What needs no read (a tradeable token, a total that fits in an + /// order, an expiry after the block) is checked with the batch's advanced structure. fn validate_state_v0( &self, platform: &PlatformStateRef, @@ -57,27 +53,6 @@ impl TokenPlaceAskTransitionActionStateValidationV0 for TokenPlaceAskTransitionA return Ok(validation_result); } - let validation_result = validate_token_tradeable(self.base())?; - if !validation_result.is_valid() { - return Ok(validation_result); - } - - let decimals = self.base().token_configuration()?.conventions().decimals(); - if TokenOrder::order_value(self.amount(), self.price(), decimals).is_none() { - return Ok(SimpleConsensusValidationResult::new_with_error( - TokenOrderValueTooLargeError::new(self.amount(), self.price(), decimals).into(), - )); - } - - if let Some(expires_at) = self.expires_at() { - if expires_at <= block_info.time_ms { - return Ok(SimpleConsensusValidationResult::new_with_error( - TokenOrderExpiredError::new(self.order_id(), expires_at, block_info.time_ms) - .into(), - )); - } - } - let validation_result = validate_token_not_paused( platform, self.token_id(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs new file mode 100644 index 00000000000..8183a7bc19a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -0,0 +1,55 @@ +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::{TokenOrderExpiredError, TokenOrderValueTooLargeError}; +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::tokens::token_order::TokenOrder; +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_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; + +use crate::error::Error; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_tradeable; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait TokenPlaceAskTransitionActionStructureValidationV0 +{ + fn validate_structure_v0( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransitionAction { + /// What a listing needs that reads nothing beyond the contract the action carries and the + /// block: a tradeable token, a total that fits in an order (`i64::MAX` credits) at the + /// token's decimals, and, when it expires, an expiry after the block. + fn validate_structure_v0( + &self, + block_info: &BlockInfo, + _platform_version: &PlatformVersion, + ) -> Result { + let validation_result = validate_token_tradeable(self.base())?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let decimals = self.base().token_configuration()?.conventions().decimals(); + if TokenOrder::order_value(self.amount(), self.price(), decimals).is_none() { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenOrderValueTooLargeError::new(self.amount(), self.price(), decimals).into(), + )); + } + + if let Some(expires_at) = self.expires_at() { + if expires_at <= block_info.time_ms { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenOrderExpiredError::new(self.order_id(), expires_at, block_info.time_ms) + .into(), + )); + } + } + + Ok(SimpleConsensusValidationResult::new()) + } +} 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 index 4f762ec3133..69c7786ae73 100644 --- 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 @@ -14,6 +14,8 @@ 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::batch::action_validation::token::token_fill_ask_transition_action::TokenFillAskTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::TokenPlaceAskTransitionActionValidation; 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 @@ -35,7 +37,9 @@ impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { /// too, rather than in the state validation check_tx skips: /// /// - a `TokenConfigUpdate` that makes a token tradeable needs a transferable token - /// (`NonTransferableTokenTradeableError`, 10281). + /// (`NonTransferableTokenTradeableError`, 10281); + /// - a `TokenPlaceAsk` needs a tradeable token, a total that fits in an order and an expiry + /// after the block, and a `TokenFillAsk` a token that is still tradeable. /// /// A failing action bumps the signer's nonce, as every advanced structure failure does. fn validate_advanced_structure_from_state_v2( @@ -67,6 +71,12 @@ impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { TokenTransitionAction::ConfigUpdateAction(config_update_action) => { config_update_action.validate_structure(platform_version)? } + TokenTransitionAction::PlaceAskAction(place_ask_action) => { + place_ask_action.validate_structure(block_info, platform_version)? + } + TokenTransitionAction::FillAskAction(fill_ask_action) => { + fill_ask_action.validate_structure(platform_version)? + } _ => continue, }; if !structure_result.is_valid() { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index e671936b2a1..32cc79624b6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -3,6 +3,8 @@ //! token market contract; a buyer takes part or all of it (`FillAsk`), paying the seller in //! credits; the seller withdraws what is left (`CancelOrder`). use super::*; +use crate::execution::check_tx::CheckTxLevel; +use crate::platform_types::platform::PlatformRef; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; @@ -165,6 +167,30 @@ impl Market { processing_result } + /// What check_tx refuses `transition` with. + fn check_tx(&self, transition: &StateTransition) -> Vec { + let platform_version = PlatformVersion::latest(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch to serialize"); + let platform_state = self.platform.state.load(); + let platform_ref = PlatformRef { + drive: &self.platform.drive, + state: &platform_state, + config: &self.platform.config, + core_rpc: &self.platform.core_rpc, + }; + self.platform + .check_tx( + &serialized, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("expected to be able to check tx") + .errors + } + fn process(&mut self, transition: &StateTransition) -> StateTransitionsProcessingResult { self.process_at(transition, &BlockInfo::default()) } @@ -565,6 +591,15 @@ async fn should_return_the_tokens_of_a_cancelled_listing() { async fn should_refuse_listing_a_token_that_is_not_tradeable() { let mut market = market_with(|_| {}, dash_to_credits!(1.0)); let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + + // The rule reads only the contract the action carries, so the mempool refuses it too. + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::StateError( + StateError::TokenNotTradeableError(_) + )] + ); + let processing_result = market.process(&place); assert_matches!( @@ -608,6 +643,12 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { ); // Two whole tokens at i64::MAX credits each. let (place, _) = market.place_ask(2, i64::MAX as u64, 1, None).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::StateError( + StateError::TokenOrderValueTooLargeError(_) + )] + ); let processing_result = market.process(&place); assert_matches!( diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index d79abb391f8..eedb352c597 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -155,9 +155,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds /// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, -/// as software that cannot decode them refuses them. Their structure and state validations -/// and their high-level operation converters are 0 in every table, which only this version -/// reaches. +/// as software that cannot decode them refuses them. The checks that read nothing beyond the +/// contract and the block (a tradeable token, a listing's value and expiry) run with the batch +/// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. +/// Their structure and state validations and their high-level operation converters are 0 in +/// every table, which only this version reaches. 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 From 9c128e6b589c56b9eb5075088879ffd8847344e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 00:04:29 +0300 Subject: [PATCH 037/121] 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 038/121] 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 3b12a5b9fe82c399e4fa1420a3f6cbfb1987a408 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 01:00:18 +0300 Subject: [PATCH 039/121] fix(drive): pass the listing escrow its batch's earlier operations add_to_token_order_escrow_operations now takes the batch's earlier operations. A transition places one listing and its operations apply as their own batch, so the listing converter passes none. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/src/util/batch/drive_op_batch/token.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 4af78db4d16..4535a751479 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -248,12 +248,17 @@ impl DriveLowLevelOperationConverter for TokenOperationType { transaction, platform_version, )?; + // A transition places one listing, and each transition's operations apply as a + // batch of their own, so no earlier operation of this batch queues the token's + // escrow tree. The balance estimation above registers its layers first, and the + // escrow's deeper Tokens estimate is kept. batch_operations.extend(drive.add_to_token_order_escrow_operations( token_id.to_buffer(), order_id.to_buffer(), amount, estimated_costs_only_with_layer_info, transaction, + &mut None, platform_version, )?); Ok(batch_operations) From d85d2c2721fa6bc60df71707951c887454f1d692 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 08:45:59 +0300 Subject: [PATCH 040/121] 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 47b4e5c8784c086209a9d13ccbc7fb9bbaff9121 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 09:20:37 +0300 Subject: [PATCH 041/121] fix(platform): token listings review round 1 - A listing records the token's decimals when it is placed and every fill prices it at them, so a conventions update cannot reprice an open listing. Fills read no token configuration. - An expiresAt above i64::MAX is refused by the structure validation with the new basic error InvalidTokenOrderExpiryError (10464), through one validate_token_order_terms shared with offers. - A listing or a fill naming a token its contract does not define bumps the nonce with ContractHasNoTokensError or InvalidTokenPositionError instead of failing as an internal error; the token base structure validation reads the same helper (token_configuration_or_position_error). - The PlaceAsk proof check also requires remaining == amount. - Drive's verify feature enables dpp/token-market-contract, so a verify-only build has the contract the order proofs read. - wasm-dpp2 listing setters convert BigInts with try_to_u64 and leave the transition unchanged when out of range. - The cannot-pay test asserts the exact unpaid IdentityInsufficientBalanceError for a buyer short of the fee and one short of the price. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 12 +- .../src/errors/consensus/basic/basic_error.rs | 13 +- .../token/invalid_token_order_expiry_error.rs | 60 ++++ .../src/errors/consensus/basic/token/mod.rs | 2 + packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../batched_transition/mod.rs | 1 + .../batched_transition/token_order_terms.rs | 88 +++++ .../validate_structure/v0/mod.rs | 38 +-- packages/rs-dpp/src/tokens/token_order.rs | 45 ++- .../structure_v0/mod.rs | 32 +- .../batch/tests/token/marketplace/mod.rs | 305 +++++++++++++++++- packages/rs-drive/Cargo.toml | 2 + .../token_base_transition_action/v0/mod.rs | 25 ++ .../v0/transformer.rs | 21 +- .../v0/transformer.rs | 50 +-- .../v0/mod.rs | 24 +- .../rs-platform-version/src/version/v15.rs | 8 +- .../src/errors/consensus/consensus_error.rs | 7 +- .../batch/token_transitions/token_fill_ask.rs | 24 +- .../token_transitions/token_place_ask.rs | 30 +- .../tests/unit/TokensTransitions.spec.ts | 74 +++++ 21 files changed, 721 insertions(+), 141 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 2273f4cc2c4..8de9a489da3 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -106,7 +106,9 @@ A listing's id follows from the token, the seller and the transition's identity ### Price and partial fills -`price` is in credits per whole token, 10 to the power of the token's decimals base units. A fill +`price` is in credits per whole token, 10 to the power of the token's decimals base units. The +listing records those decimals when it is placed and every fill prices it at them, so a later +change to the token's decimals does not change what the rest of the listing costs. A fill takes as much as it may: everything open, up to `maxAmount`. A listing never keeps less than `minFill` open, so a fill that would leave less takes less, leaving exactly `minFill`; a fill that does not take everything open takes at least `minFill`. A `minFill` equal to `amount` makes @@ -128,8 +130,9 @@ The rounding favours the seller and is never more than one credit per fill. | Rule | Refused with | |---|---| -| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463) | +| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, `expiresAt` at most `i64::MAX` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderExpiryError` (10464) | | A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | +| The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` | | The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | | The order is open, for this token | `TokenOrderNotFoundError` (40751) | | A listing's `expiresAt` is after the block, and a fill comes before it | `TokenOrderExpiredError` (40752) | @@ -142,8 +145,9 @@ The rounding favours the seller and is never more than one credit per fill. | The token is not paused (to place or fill) | `TokenIsPausedError` | | The seller's account is not frozen (to place or fill), nor the buyer's unless the token allows transfers to frozen balances | `IdentityTokenAccountFrozenError` | -The state rules are paid: a refused transition bumps the signer's nonce and charges the work that -ran. The rules that read nothing beyond the contract and the block (a tradeable token, a listing's +The rules on a transition's own fields (the first two rows) are checked before anything is read and +refused unpaid. The state rules are paid: a refused transition bumps the signer's nonce and charges +the work that ran. The rules that read nothing beyond the contract and the block (a tradeable token, a listing's value and its expiry) run with the batch's advanced structure, so check_tx refuses them too. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are checked with the batch's fees, which count a fill's cost like a direct purchase. 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 bfad2b32f12..86f9e39118f 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -112,7 +112,7 @@ use crate::consensus::basic::state_transition::{ WithdrawalBelowMinAmountError, }; use crate::consensus::basic::token::{ - InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, + InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, }; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, @@ -880,6 +880,10 @@ pub enum BasicError { // A token order's structure (protocol version 15). #[error(transparent)] InvalidTokenOrderFillRangeError(InvalidTokenOrderFillRangeError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderExpiryError(InvalidTokenOrderExpiryError), } impl From for ConsensusError { @@ -1110,6 +1114,13 @@ mod tests { )), 208 ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderExpiryError( + InvalidTokenOrderExpiryError::new(0, 1) + )), + 209 + ); } /// 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/token/invalid_token_order_expiry_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs new file mode 100644 index 00000000000..afae736c5e1 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs @@ -0,0 +1,60 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::prelude::TimestampMillis; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A token order's expiry is a block time in milliseconds that the order's document stores as a +/// signed 8-byte value, so it is at most `i64::MAX`. Whether it is after the block is checked +/// when the order is placed. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order expiry {expires_at} must be at most {max_expires_at} milliseconds")] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderExpiryError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + expires_at: TimestampMillis, + max_expires_at: TimestampMillis, +} + +impl InvalidTokenOrderExpiryError { + pub fn new(expires_at: TimestampMillis, max_expires_at: TimestampMillis) -> Self { + Self { + expires_at, + max_expires_at, + } + } + + pub fn expires_at(&self) -> TimestampMillis { + self.expires_at + } + + pub fn max_expires_at(&self) -> TimestampMillis { + self.max_expires_at + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderExpiryError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderExpiryError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index ad5c0a660bc..b9ea2bc301e 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -39,3 +39,5 @@ mod invalid_token_order_price_error; pub use invalid_token_order_price_error::*; mod invalid_token_order_fill_range_error; pub use invalid_token_order_fill_range_error::*; +mod invalid_token_order_expiry_error; +pub use invalid_token_order_expiry_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 63c09f650f3..761e966d663 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -128,6 +128,7 @@ impl ErrorWithCode for BasicError { Self::NonTransferableTokenTradeableError(_) => 10281, Self::InvalidTokenOrderPriceError(_) => 10462, Self::InvalidTokenOrderFillRangeError(_) => 10463, + Self::InvalidTokenOrderExpiryError(_) => 10464, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs index 73e3574c3a3..91216d629b6 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs @@ -30,6 +30,7 @@ pub mod token_fill_ask_transition; pub mod token_freeze_transition; pub mod token_mint_to_pool_transition; pub mod token_mint_transition; +mod token_order_terms; pub mod token_place_ask_transition; pub mod token_set_price_for_direct_purchase_transition; pub mod token_shield_transition; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs new file mode 100644 index 00000000000..ddb36d93277 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs @@ -0,0 +1,88 @@ +//! The terms of a new token order (protocol version 15), checked alike for a listing +//! (`TokenPlaceAsk`) and an offer (`TokenPlaceBid`). + +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::token::{ + InvalidTokenAmountError, InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, + InvalidTokenOrderPriceError, +}; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::prelude::TimestampMillis; +use crate::validation::SimpleConsensusValidationResult; + +/// The largest amount, price and expiry an order may carry: its document stores each as a signed +/// 8-byte value, and its value is computed from the amount and the price in u128. +pub(crate) const MAX_TOKEN_ORDER_TERM: u64 = i64::MAX as u64; + +/// The amount and the price are between 1 and `i64::MAX`, the minimum fill between 1 and the +/// amount, and an expiry at most `i64::MAX`. Whether the whole order's value fits and whether it +/// expires after the block are checked with the batch's advanced structure, which knows the +/// token's decimals and the block. +pub(crate) fn validate_token_order_terms( + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + expires_at: Option, +) -> SimpleConsensusValidationResult { + if amount == 0 || amount > MAX_TOKEN_ORDER_TERM { + return SimpleConsensusValidationResult::new_with_error(ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(InvalidTokenAmountError::new( + MAX_TOKEN_ORDER_TERM, + amount, + )), + )); + } + if price == 0 || price > MAX_TOKEN_ORDER_TERM { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderPriceError::new(price, MAX_TOKEN_ORDER_TERM).into(), + ); + } + if min_fill == 0 || min_fill > amount { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(min_fill, amount).into(), + ); + } + if let Some(expires_at) = expires_at { + if expires_at > MAX_TOKEN_ORDER_TERM { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderExpiryError::new(expires_at, MAX_TOKEN_ORDER_TERM).into(), + ); + } + } + SimpleConsensusValidationResult::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn errors( + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + expires_at: Option, + ) -> Vec { + validate_token_order_terms(amount, price, min_fill, expires_at).errors + } + + #[test] + fn should_accept_an_expiry_up_to_i64_max() { + assert!(errors(1_000, 1, 1, None).is_empty()); + assert!(errors(1_000, 1, 1, Some(0)).is_empty()); + assert!(errors(1_000, 1, 1, Some(MAX_TOKEN_ORDER_TERM)).is_empty()); + } + + #[test] + fn should_refuse_an_expiry_above_i64_max() { + for expires_at in [MAX_TOKEN_ORDER_TERM + 1, u64::MAX] { + assert!(matches!( + errors(1_000, 1, 1, Some(expires_at)).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(error))] + if error.expires_at() == expires_at + && error.max_expires_at() == MAX_TOKEN_ORDER_TERM + )); + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs index d893880ee16..1ab3097b572 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -1,43 +1,21 @@ -use crate::consensus::basic::token::{ - InvalidTokenAmountError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, -}; -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; +use crate::state_transition::batch_transition::batched_transition::token_order_terms::validate_token_order_terms; use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; use crate::state_transition::batch_transition::TokenPlaceAskTransition; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; -/// The largest amount and price an order may carry: both are held as signed 8-byte values (an -/// order's fields and its escrow entry), and the order's value is computed from them in u128. -const MAX_ORDER_VALUE: u64 = i64::MAX as u64; - pub(super) trait TokenPlaceAskTransitionStructureValidationV0 { fn validate_structure_v0(&self) -> Result; } impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { - /// The amount and the price are between 1 and `i64::MAX`, and the minimum fill between 1 and - /// the amount. Whether the whole order's value fits is checked in state validation, which - /// knows the token's decimals. + /// The listing's terms, checked as an offer's are: see `validate_token_order_terms`. fn validate_structure_v0(&self) -> Result { - if self.amount() == 0 || self.amount() > MAX_ORDER_VALUE { - return Ok(SimpleConsensusValidationResult::new_with_error( - ConsensusError::BasicError(BasicError::InvalidTokenAmountError( - InvalidTokenAmountError::new(MAX_ORDER_VALUE, self.amount()), - )), - )); - } - if self.price() == 0 || self.price() > MAX_ORDER_VALUE { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidTokenOrderPriceError::new(self.price(), MAX_ORDER_VALUE).into(), - )); - } - if self.min_fill() == 0 || self.min_fill() > self.amount() { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidTokenOrderFillRangeError::new(self.min_fill(), self.amount()).into(), - )); - } - Ok(SimpleConsensusValidationResult::default()) + Ok(validate_token_order_terms( + self.amount(), + self.price(), + self.min_fill(), + self.expires_at(), + )) } } diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs index 14f8c68a719..3f6e0122102 100644 --- a/packages/rs-dpp/src/tokens/token_order.rs +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -17,12 +17,15 @@ pub mod property_names { pub const MIN_FILL: &str = "minFill"; pub const EXPIRES_AT: &str = "expiresAt"; pub const ESCROW: &str = "escrow"; + pub const DECIMALS: &str = "decimals"; } /// An open order of the token marketplace (protocol version 15), as the token market contract /// stores it: a listing (`Ask`) of tokens for sale, or an offer (`Bid`) to buy them. /// -/// The price is in credits per whole token, 10 to the power of the token's decimals base units. +/// The price is in credits per whole token: 10 to the power of `decimals` base units, the token's +/// decimals when the order was placed. Every fill prices the order at that scale, so a later +/// change to the token's decimals changes neither what a listing costs nor what an offer pays. /// A fill pays the change in the order's rounded running total, so splitting an order into /// several fills never changes what it costs in all: a listing's buyers pay /// `ceil((filled + q) * price / unit) - ceil(filled * price / unit)` for `q` base units, and an @@ -52,6 +55,8 @@ pub struct TokenOrder { /// For an offer, the credits still held in escrow; 0 for a listing, whose escrow holds /// tokens (`remaining`). pub escrow: Credits, + /// The token's decimals when the order was placed, the scale of its price. + pub decimals: u8, } fn unit(decimals: u8) -> Result { @@ -111,13 +116,9 @@ impl TokenOrder { /// The credits a fill of `fill_amount` moves: what a listing's buyer pays, rounded up, or /// what an offer's seller receives, rounded down, as the change in the order's running total - /// for a token with `decimals` decimals. - pub fn fill_credits( - &self, - fill_amount: TokenAmount, - decimals: u8, - ) -> Result { - let unit = unit(decimals)?; + /// at the order's own `decimals`. + pub fn fill_credits(&self, fill_amount: TokenAmount) -> Result { + let unit = unit(self.decimals)?; let filled = self.filled(); let filled_after = filled .checked_add(fill_amount) @@ -179,6 +180,11 @@ impl TokenOrder { TokenOrderSide::Ask => 0, TokenOrderSide::Bid => integer(property_names::ESCROW)?, }; + let decimals = u8::try_from(integer(property_names::DECIMALS)?).map_err(|_| { + ProtocolError::CorruptedCodeExecution( + "a token order document's decimals are above 255".to_string(), + ) + })?; Ok(TokenOrder { id: document.id(), side, @@ -190,6 +196,7 @@ impl TokenOrder { min_fill: integer(property_names::MIN_FILL)?, expires_at, escrow, + decimals, }) } @@ -213,6 +220,10 @@ impl TokenOrder { signed(self.remaining)?, ), (property_names::MIN_FILL.to_string(), signed(self.min_fill)?), + ( + property_names::DECIMALS.to_string(), + Value::I64(self.decimals as i64), + ), ]); if let Some(expires_at) = self.expires_at { properties.insert(property_names::EXPIRES_AT.to_string(), signed(expires_at)?); @@ -285,6 +296,7 @@ mod tests { min_fill, expires_at: None, escrow: 0, + decimals: 2, } } @@ -323,13 +335,13 @@ mod tests { let mut listing = order(TokenOrderSide::Ask, 3, 3, 1); let mut paid = vec![]; for _ in 0..3 { - let credits = listing.fill_credits(1, 2).expect("expected the cost"); + let credits = listing.fill_credits(1).expect("expected the cost"); paid.push(credits); listing.remaining -= 1; } assert_eq!(paid, vec![4, 3, 3]); let whole = order(TokenOrderSide::Ask, 3, 3, 1); - assert_eq!(whole.fill_credits(3, 2).expect("expected the cost"), 10); + assert_eq!(whole.fill_credits(3).expect("expected the cost"), 10); } #[test] @@ -340,13 +352,24 @@ mod tests { let mut paid = 0; while offer.remaining > 0 { let take = offer.remaining.min(7); - paid += offer.fill_credits(take, 2).expect("expected the payout"); + paid += offer.fill_credits(take).expect("expected the payout"); offer.remaining -= take; } assert_eq!(paid, 3333); assert!(paid <= escrow); } + /// An order prices its fills at the decimals it records, whatever the token's are now: the + /// same price and amount cost 100 times more at 0 decimals than at 2. + #[test] + fn should_price_fills_at_the_orders_own_decimals() { + let mut listing = order(TokenOrderSide::Ask, 100, 100, 1); + listing.price = 100; + assert_eq!(listing.fill_credits(10).expect("expected the cost"), 10); + listing.decimals = 0; + assert_eq!(listing.fill_credits(10).expect("expected the cost"), 1_000); + } + #[test] fn should_refuse_an_order_worth_more_than_an_order_holds() { assert_eq!(TokenOrder::order_value(i64::MAX as u64, 2, 0), None); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs index 4b543cd8014..3d076c01344 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs @@ -1,8 +1,3 @@ -use dpp::consensus::basic::BasicError; -use dpp::consensus::basic::token::ContractHasNoTokensError; -use dpp::consensus::basic::token::InvalidTokenPositionError; -use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::validation::{SimpleConsensusValidationResult}; use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use dpp::version::PlatformVersion; @@ -19,26 +14,11 @@ impl TokenBaseTransitionActionStructureValidationV0 for TokenBaseTransitionActio &self, _platform_version: &PlatformVersion, ) -> Result { - let token_position = self.token_position(); - let contract = self.data_contract_fetch_info_ref(); - if contract.contract.tokens().get(&token_position).is_none() { - return if contract.contract.tokens().is_empty() { - Ok(SimpleConsensusValidationResult::new_with_error( - BasicError::ContractHasNoTokensError(ContractHasNoTokensError::new( - contract.contract.id(), - )) - .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::new_with_error( - BasicError::InvalidTokenPositionError(InvalidTokenPositionError::new( - contract.contract.tokens().keys().last().copied(), - token_position, - )) - .into(), - )) - }; - } - Ok(SimpleConsensusValidationResult::default()) + // The rule lives with the action, where a transformer that needs the token's + // configuration earlier reads it too. + Ok(match self.token_configuration_or_position_error() { + Ok(_) => SimpleConsensusValidationResult::default(), + Err(error) => SimpleConsensusValidationResult::new_with_error(error), + }) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 32cc79624b6..b989ef825f2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -9,7 +9,9 @@ use crate::platform_types::state_transitions_processing_result::StateTransitions use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Setters; +use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; 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::DataContract; @@ -18,6 +20,8 @@ use dpp::fee::Credits; use dpp::prelude::{Identity, IdentityPublicKey}; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; +use dpp::system_data_contracts::SystemDataContract; +use dpp::tokens::calculate_token_id; use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; @@ -341,6 +345,81 @@ impl Market { self.assert_executes(&transition, &BlockInfo::default()); } + /// The seller, the contract owner, changes the token's decimals. + async fn set_decimals(&mut self, decimals: u8) { + let platform_version = PlatformVersion::latest(); + let mut conventions = self + .contract + .tokens() + .get(&0) + .expect("expected the token") + .conventions() + .clone(); + conventions.set_decimals(decimals); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_config_update_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenConfigurationChangeItem::Conventions(conventions), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the conventions update"); + self.assert_executes(&transition, &BlockInfo::default()); + } + + /// A listing by the seller of the token at `position` of the contract `contract_id`, with + /// the seller's identity contract nonce `nonce` for that contract. + async fn place_ask_naming( + &mut self, + contract_id: Identifier, + position: u16, + nonce: u64, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + BatchTransition::new_token_place_ask_transition( + Identifier::from(calculate_token_id(contract_id.as_bytes(), position)), + self.seller.id(), + contract_id, + position, + 1_000, + PRICE, + 1, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing") + } + + /// The identity contract nonce `identity_id` last used for the token's contract. + fn contract_nonce(&self, identity_id: Identifier) -> Option { + self.platform + .drive + .fetch_identity_contract_nonce( + identity_id.to_buffer(), + self.contract.id().to_buffer(), + true, + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the nonce") + } + fn token_balance(&self, identity_id: Identifier) -> Option { self.platform .drive @@ -462,6 +541,7 @@ async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { min_fill: 100, expires_at: Some(9_000), escrow: 0, + decimals: 8, } ); market.assert_supply_balanced(); @@ -815,23 +895,226 @@ async fn should_refuse_a_fill_of_a_paused_token_but_still_cancel() { assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); } +/// The batch's balance check counts what a fill pays: a buyer whose credits cover the price but +/// not the fee, and one whose credits do not cover the price, are both refused before anything +/// is charged, and neither the buyer, the listing nor the buyer's nonce changes. #[tokio::test] async fn should_not_fill_a_listing_the_buyer_cannot_pay_for() { let mut market = market_with(make_token_tradeable, dash_to_credits!(0.0001)); - // 10,000,000 credits, more than the buyer's balance. - let (place, order_id) = market.place_ask(1_000, 1_000_000_000_000, 1, None).await; + let buyer_credits = market.credit_balance(market.buyer.id()); + // 10,000,000 credits for the whole listing: the buyer's balance, short of the fee. + let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + // 20,000,000 credits: twice the buyer's balance. + let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000, 1, None).await; market.assert_executes(&place, &BlockInfo::default()); + assert_eq!(buyer_credits, 10_000_000); + let buyer_nonce = market.contract_nonce(market.buyer.id()); - let fill = market.fill_ask(order_id, 1, 1_000, u64::MAX >> 1).await; - let processing_result = market.process(&fill); - assert!( - !matches!( + for order_id in [at_balance, above_balance] { + let fill = market.fill_ask(order_id, 1, 1_000, u64::MAX >> 1).await; + let processing_result = market.process(&fill); + assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }] - ), - "a fill the buyer cannot pay for must not execute: {:?}", - processing_result.execution_results() + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(_)) + )], + "a fill the buyer cannot pay for is refused unpaid: {:?}", + processing_result.execution_results() + ); + assert_eq!(market.credit_balance(market.buyer.id()), buyer_credits); + assert_eq!(market.contract_nonce(market.buyer.id()), buyer_nonce); + assert_eq!(market.token_balance(market.buyer.id()), None); + assert_eq!(market.escrow(order_id), Some(1_000)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(1_000) + ); + } +} + +/// A listing records the decimals its token had when it was placed and every fill prices it at +/// them. The token changes from 8 decimals to 2 between two fills: priced at 2, 100 base units +/// of the listing would cost 1,000,000,000 credits, and the buyer's limit of 1,000 would refuse +/// the fill; priced at the listing's 8, they cost 1,000. +#[tokio::test] +async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration.set_conventions_change_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!( + market.listing(order_id).map(|listing| listing.decimals), + Some(8) + ); + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 100, 100, 1_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 1_000 + ); + + market.set_decimals(2).await; + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 100, 100, 1_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 1_000, + "the fill is priced at the listing's decimals" + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(200)); + let listing = market.listing(order_id).expect("expected the listing"); + assert_eq!((listing.remaining, listing.decimals), (800, 8)); + + // A listing placed after the change records the new decimals. + let (place, newer) = market.place_ask(10, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!( + market.listing(newer).map(|listing| listing.decimals), + Some(2) + ); + market.assert_supply_balanced(); +} + +/// A listing's expiry is stored as a signed 8-byte value: `i64::MAX` is a valid expiry, and one +/// more is refused by the structure validation, in check_tx and in a block, before anything is +/// charged. +#[tokio::test] +async fn should_refuse_a_listing_expiry_above_i64_max() { + let mut market = tradeable_market(); + let latest = i64::MAX as u64; + + let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(latest)).await; + assert!(market.check_tx(&place).is_empty()); + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!( + market + .listing(order_id) + .and_then(|listing| listing.expires_at), + Some(latest) + ); + + let seller_credits = market.credit_balance(market.seller.id()); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(latest + 1)).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(error))] + if error.expires_at() == latest + 1 + ); + let processing_result = market.process(&place); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(_)) + )] if error.code() == 10464 + ); + assert_eq!(market.listing(order_id), None); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); +} + +/// A listing naming a token its contract does not define is refused with the token base +/// rules' error, charged, in check_tx and in a block: a position past the contract's last +/// token, and a contract without tokens (DPNS). +#[tokio::test] +async fn should_refuse_a_listing_of_a_token_the_contract_does_not_define() { + let mut market = tradeable_market(); + let contract_id = market.contract.id(); + let nonce = market.seller.take_nonce(); + let place = market.place_ask_naming(contract_id, 1, nonce).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenPositionError(error))] + if error.invalid_token_position() == 1 + ); + let processing_result = market.process(&place); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)), + .. + }] + ); + + let dpns_id = SystemDataContract::DPNS.id(); + let place = market.place_ask_naming(dpns_id, 0, 1).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError( + BasicError::ContractHasNoTokensError(_) + )] + ); + let processing_result = market.process(&place); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractHasNoTokensError(_)), + .. + }] + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); +} + +/// A fill naming a token its contract does not define is refused with the token base rules' +/// error before the listing is read, charged, in check_tx and in a block. +#[tokio::test] +async fn should_refuse_a_fill_of_a_token_the_contract_does_not_define() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let platform_version = PlatformVersion::latest(); + let nonce = market.buyer.take_nonce(); + let fill = BatchTransition::new_token_fill_ask_transition( + Identifier::from(calculate_token_id(market.contract.id().as_bytes(), 1)), + market.buyer.id(), + market.contract.id(), + 1, + order_id, + 1, + 1_000, + u64::MAX >> 1, + &market.buyer.key, + nonce, + 0, + &market.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill"); + assert_matches!( + market.check_tx(&fill).as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenPositionError(_) + )] + ); + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)), + .. + }] ); - assert_eq!(market.token_balance(market.buyer.id()), None); assert_eq!(market.escrow(order_id), Some(1_000)); } diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index 38ea8666225..a7b3dfcfa03 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -157,5 +157,7 @@ verify = [ "dpp/state-transitions", "dpp/system_contracts", "dpp/token-history-contract", + # The token marketplace's proofs read the token market contract's orders. + "dpp/token-market-contract", "dpp/withdrawals-contract", ] diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs index 64fdfb2956e..2cc15b52ec1 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs @@ -1,5 +1,8 @@ use crate::drive::contract::DataContractFetchInfo; use crate::error::Error; +use dpp::consensus::basic::token::{ContractHasNoTokensError, InvalidTokenPositionError}; +use dpp::consensus::basic::BasicError; +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::TokenConfiguration; @@ -60,6 +63,28 @@ pub trait TokenBaseTransitionActionAccessorsV0 { /// Gets the token configuration associated to the action fn token_configuration(&self) -> Result<&TokenConfiguration, Error>; + /// The configuration of the token at the action's position, or the consensus error a + /// transition naming a token its contract does not define is refused with: + /// `ContractHasNoTokensError` when the contract defines no token, `InvalidTokenPositionError` + /// otherwise. The batch's advanced structure validation refuses such a transition with it, + /// and a transformer that needs the token's configuration earlier reads it through this. + fn token_configuration_or_position_error(&self) -> Result<&TokenConfiguration, ConsensusError> { + let contract = &self.data_contract_fetch_info_ref().contract; + let token_position = self.token_position(); + contract.tokens().get(&token_position).ok_or_else(|| { + if contract.tokens().is_empty() { + BasicError::ContractHasNoTokensError(ContractHasNoTokensError::new(contract.id())) + .into() + } else { + BasicError::InvalidTokenPositionError(InvalidTokenPositionError::new( + contract.tokens().keys().last().copied(), + token_position, + )) + .into() + } + }) + } + /// Gets the store_in_group field (optional) fn store_in_group(&self) -> Option<&GroupStateTransitionResolvedInfo>; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index 6ee06061cd6..7620eef75e3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -6,8 +6,6 @@ use dpp::consensus::state::token::{ TokenOrderPriceLimitError, TokenOrderSelfFillError, }; use dpp::consensus::ConsensusError; -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::identifier::Identifier; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::TokenFillAskTransitionV0; @@ -28,10 +26,10 @@ use crate::state_transition_action::system::bump_identity_data_contract_nonce_ac impl TokenFillAskTransitionActionV0 { /// Converts a borrowed `TokenFillAskTransitionV0` into an action: reads the listing, and - /// works out the amount the fill takes and the credits the buyer pays. A listing that is not - /// open for this token, the buyer's own listing, an expired listing, a fill range the - /// listing cannot meet and a cost above the buyer's limit each bump the nonce with their - /// error instead. + /// works out the amount the fill takes and the credits the buyer pays. A token its contract + /// does not define, a listing that is not open for this token, the buyer's own listing, an + /// expired listing, a fill range the listing cannot meet and a cost above the buyer's limit + /// each bump the nonce with their error instead. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( drive: &Drive, @@ -102,6 +100,12 @@ impl TokenFillAskTransitionActionV0 { false => return bump(base_action_validation_result.errors, fee_result), }; + // Refused with the token base rules' error before anything is read for a token the + // contract does not define. + if let Err(error) = base_action.token_configuration_or_position_error() { + return bump(vec![error], fee_result); + } + let token_market_contract = drive.token_market_contract_fetch_info(platform_version)?; let (order_document, read_cost) = drive.fetch_token_order_document( &token_market_contract.contract, @@ -156,8 +160,9 @@ impl TokenFillAskTransitionActionV0 { fee_result, ); }; - let decimals = base_action.token_configuration()?.conventions().decimals(); - let credits = order.fill_credits(fill_amount, decimals)?; + // Priced at the decimals the token had when the listing was placed, which the listing + // records: a later change to the token's decimals does not reprice it. + let credits = order.fill_credits(fill_amount)?; if credits > *max_total_price { return bump( vec![TokenOrderPriceLimitError::new(*order_id, credits, *max_total_price).into()], diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index 79295a156cd..703594394db 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -7,13 +7,16 @@ use dpp::state_transition::batch_transition::token_place_ask_transition::v0::Tok use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use dpp::ProtocolError; +use dpp::consensus::ConsensusError; +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::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; use crate::drive::Drive; use crate::error::Error; -use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::v0::TokenPlaceAskTransitionActionV0; use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; use crate::state_transition_action::batch::BatchedTransitionAction; @@ -21,8 +24,11 @@ use crate::state_transition_action::system::bump_identity_data_contract_nonce_ac impl TokenPlaceAskTransitionActionV0 { /// Converts a borrowed `TokenPlaceAskTransitionV0` into an action. The listing's order id - /// follows from the token, the seller and the nonce; everything the listing needs from state - /// (the token's rules, the seller's balance) is checked by the state validation. + /// follows from the token, the seller and the nonce, and it records the token's decimals, + /// which price its fills; a transition naming a token its contract does not define bumps the + /// nonce with `ContractHasNoTokensError` or `InvalidTokenPositionError`. Everything else the + /// listing needs from state (the token's rules, the seller's balance) is checked by the + /// state validation. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( drive: &Drive, @@ -72,23 +78,30 @@ impl TokenPlaceAskTransitionActionV0 { None, )?; + let bump = |errors: Vec, fee_result: FeeResult| { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( + base, + owner_id, + user_fee_increase, + ); + Ok(( + ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + errors, + ), + fee_result, + )) + }; + let (base_action, _change_note) = match base_action_validation_result.is_valid() { true => base_action_validation_result.into_data()?, - false => { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( - base, - owner_id, - user_fee_increase, - ); - return Ok(( - ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), - base_action_validation_result.errors, - ), - fee_result, - )); - } + false => return bump(base_action_validation_result.errors, fee_result), + }; + + let decimals = match base_action.token_configuration_or_position_error() { + Ok(token_configuration) => token_configuration.conventions().decimals(), + Err(error) => return bump(vec![error], fee_result), }; let order_id = @@ -104,6 +117,7 @@ impl TokenPlaceAskTransitionActionV0 { min_fill: *min_fill, expires_at: *expires_at, escrow: 0, + decimals, } .new_document(block_info)?; 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 02cfc460db9..925e322eabf 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 @@ -1363,6 +1363,8 @@ impl Drive { || order.maker_id != owner_id || order.price != token_place_ask_transition.price() || order.amount != token_place_ask_transition.amount() + // A listing just placed has nothing filled yet. + || order.remaining != token_place_ask_transition.amount() || order.min_fill != token_place_ask_transition.min_fill() || order.expires_at != token_place_ask_transition.expires_at() { @@ -3388,17 +3390,6 @@ impl Drive { } } -/// A moderator's document deletion is proved by the record it left: the one of the document -/// named, saying that the transition's signer removed it for the transition's reason, and not -/// marked restored. When is the block's to say, and whose the document was only the record -/// knows. A document id is -/// produced at most once, so the record is of that document and of no other. -/// -/// On a type whose moderators' deletions keep no record -/// (`moderatorAbilities.deleteKeepsRecord: false`) it is proved by the document's absence, -/// and verifies to `VerifiedDocuments` with the document named and none. That the type keeps -/// none is read from the contract, when the provider holds it: a deletion of a type that keeps -/// records is proved by its record without the contract, as it always was. /// Verifies the proof of one token market contract order document: the document when the order /// is open, `None` when it is not. fn verify_token_order_document( @@ -3429,6 +3420,17 @@ fn verify_token_order_document( ) } +/// A moderator's document deletion is proved by the record it left: the one of the document +/// named, saying that the transition's signer removed it for the transition's reason, and not +/// marked restored. When is the block's to say, and whose the document was only the record +/// knows. A document id is +/// produced at most once, so the record is of that document and of no other. +/// +/// On a type whose moderators' deletions keep no record +/// (`moderatorAbilities.deleteKeepsRecord: false`) it is proved by the document's absence, +/// and verifies to `VerifiedDocuments` with the document named and none. That the type keeps +/// none is read from the contract, when the provider holds it: a deletion of a type that keeps +/// records is proved by its record without the contract, as it always was. fn verify_contract_document_deletion_execution( proof: &[u8], transition: &ContractUserModerationTransition, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 957a6cb5685..717853aaf6e 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -163,8 +163,12 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// as software that cannot decode them refuses them. The checks that read nothing beyond the /// contract and the block (a tradeable token, a listing's value and expiry) run with the batch /// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. -/// Their structure and state validations and their high-level operation converters are 0 in -/// every table, which only this version reaches. +/// A listing's terms, its expiry included (at most `i64::MAX`, `InvalidTokenOrderExpiryError` +/// 10464), are checked with the transition's structure, unpaid. A listing records the token's +/// decimals and its fills are priced at them; a listing of a token its contract does not +/// define bumps the nonce with the token base rules' error. Their structure and state +/// validations and their high-level operation converters are 0 in every table, which only this +/// version reaches. 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 diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 3d2d20fec2d..cfa2a13a48f 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -6,7 +6,9 @@ use crate::errors::consensus::basic::{ InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, UnsupportedProtocolVersionErrorWasm, UnsupportedVersionErrorWasm, }; -use dpp::consensus::basic::token::{InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError}; +use dpp::consensus::basic::token::{ + InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, +}; use dpp::consensus::state::token::{ TokenNotTradeableError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderNotFoundError, TokenOrderNotOwnedError, TokenOrderPriceLimitError, @@ -1460,6 +1462,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidTokenOrderFillRangeError(e) => { generic_consensus_error!(InvalidTokenOrderFillRangeError, e).into() } + BasicError::InvalidTokenOrderExpiryError(e) => { + generic_consensus_error!(InvalidTokenOrderExpiryError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs index 7c52e6ec07e..d5a8a338e55 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs @@ -119,28 +119,34 @@ impl TokenFillAskTransitionWasm { Ok(()) } + // Each integer setter converts with `try_to_u64` and leaves the transition unchanged when the + // value is out of range, rather than letting the ABI narrow it to another valid term. #[wasm_bindgen(setter = minAmount)] pub fn set_min_amount( &mut self, - #[wasm_bindgen(js_name = "minAmount")] min_amount: TokenAmount, - ) { - self.0.set_min_amount(min_amount) + #[wasm_bindgen(js_name = "minAmount")] min_amount: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0.set_min_amount(try_to_u64(min_amount, "minAmount")?); + Ok(()) } #[wasm_bindgen(setter = maxAmount)] pub fn set_max_amount( &mut self, - #[wasm_bindgen(js_name = "maxAmount")] max_amount: TokenAmount, - ) { - self.0.set_max_amount(max_amount) + #[wasm_bindgen(js_name = "maxAmount")] max_amount: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0.set_max_amount(try_to_u64(max_amount, "maxAmount")?); + Ok(()) } #[wasm_bindgen(setter = maxTotalPrice)] pub fn set_max_total_price( &mut self, - #[wasm_bindgen(js_name = "maxTotalPrice")] max_total_price: Credits, - ) { - self.0.set_max_total_price(max_total_price) + #[wasm_bindgen(js_name = "maxTotalPrice")] max_total_price: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0 + .set_max_total_price(try_to_u64(max_total_price, "maxTotalPrice")?); + Ok(()) } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs index 174542cd386..99eff49c0fd 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -110,27 +110,39 @@ impl TokenPlaceAskTransitionWasm { self.0.set_base(base.into()) } + // Each integer setter converts with `try_to_u64` and leaves the transition unchanged when the + // value is out of range, rather than letting the ABI narrow it to another valid term. #[wasm_bindgen(setter = amount)] - pub fn set_amount(&mut self, amount: TokenAmount) { - self.0.set_amount(amount) + pub fn set_amount(&mut self, amount: &js_sys::BigInt) -> WasmDppResult<()> { + self.0.set_amount(try_to_u64(amount, "amount")?); + Ok(()) } #[wasm_bindgen(setter = price)] - pub fn set_price(&mut self, price: Credits) { - self.0.set_price(price) + pub fn set_price(&mut self, price: &js_sys::BigInt) -> WasmDppResult<()> { + self.0.set_price(try_to_u64(price, "price")?); + Ok(()) } #[wasm_bindgen(setter = minFill)] - pub fn set_min_fill(&mut self, #[wasm_bindgen(js_name = "minFill")] min_fill: TokenAmount) { - self.0.set_min_fill(min_fill) + pub fn set_min_fill( + &mut self, + #[wasm_bindgen(js_name = "minFill")] min_fill: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0.set_min_fill(try_to_u64(min_fill, "minFill")?); + Ok(()) } #[wasm_bindgen(setter = expiresAt)] pub fn set_expires_at( &mut self, - #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, - ) { - self.0.set_expires_at(expires_at) + #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, + ) -> WasmDppResult<()> { + let expires_at = expires_at + .map(|value| try_to_u64(&value, "expiresAt")) + .transpose()?; + self.0.set_expires_at(expires_at); + Ok(()) } } diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index 2751f3b4d8b..edc995528df 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -782,4 +782,78 @@ describe('TokenTransitions', () => { }); }); }); + + describe('TokenPlaceAskTransition', () => { + const listing = () => new wasm.TokenPlaceAskTransition({ + base: baseTransition, + amount: BigInt(1000), + price: BigInt(100), + minFill: BigInt(1), + expiresAt: BigInt(5000), + }); + + it('should set the integer terms', () => { + const transition = listing(); + + transition.amount = BigInt(2000); + transition.price = BigInt(200); + transition.minFill = BigInt(10); + transition.expiresAt = BigInt(6000); + + expect(transition.amount).to.equal(BigInt(2000)); + expect(transition.price).to.equal(BigInt(200)); + expect(transition.minFill).to.equal(BigInt(10)); + expect(transition.expiresAt).to.equal(BigInt(6000)); + + transition.expiresAt = undefined; + + expect(transition.expiresAt).to.be.undefined(); + }); + + ['amount', 'price', 'minFill', 'expiresAt'].forEach((term) => { + it(`should refuse an out-of-range ${term} and keep the previous one`, () => { + const transition = listing(); + const previous = transition[term]; + + [BigInt(1) << BigInt(64), BigInt(-1)].forEach((value) => { + expect(() => { transition[term] = value; }).to.throw(); + expect(transition[term]).to.equal(previous); + }); + }); + }); + }); + + describe('TokenFillAskTransition', () => { + const fill = () => new wasm.TokenFillAskTransition({ + base: baseTransition, + orderId: ownerId, + minAmount: BigInt(1), + maxAmount: BigInt(1000), + maxTotalPrice: BigInt(100000), + }); + + it('should set the integer terms', () => { + const transition = fill(); + + transition.minAmount = BigInt(5); + transition.maxAmount = BigInt(500); + transition.maxTotalPrice = BigInt(50000); + + expect(transition.minAmount).to.equal(BigInt(5)); + expect(transition.maxAmount).to.equal(BigInt(500)); + expect(transition.maxTotalPrice).to.equal(BigInt(50000)); + }); + + ['minAmount', 'maxAmount', 'maxTotalPrice'].forEach((term) => { + it(`should refuse an out-of-range ${term} and keep the previous one`, () => { + const transition = fill(); + const previous = transition[term]; + + [BigInt(1) << BigInt(64), BigInt(-1)].forEach((value) => { + expect(() => { transition[term] = value; }).to.throw(); + expect(transition[term]).to.equal(previous); + }); + }); + }); + }); }); From 94ef9035e975dccfaf1c14e7ac834270f29088b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 09:40:33 +0300 Subject: [PATCH 042/121] 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 043/121] 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 044/121] 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 ce96fcb3a670bf10aa44c36679d2e8a98ec4d885 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 11:32:58 +0300 Subject: [PATCH 045/121] fix(drive): prove a listing's placement after it is partly filled The PlaceAsk proof accepted only an untouched ask, so a client asking for the proof after a fill could not confirm its placement. The order id follows from the token, the signer and the nonce, so only this transition creates an ask with it: one holding the transition's terms proves the placement with any remaining from the amount down to 1. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 57 +++++++++++++++++++ .../v0/mod.rs | 15 +++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index b989ef825f2..6d46793120a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -490,6 +490,32 @@ impl Market { ); } + /// Proves the executed transition the way the node answers a wait, and verifies the proof + /// the way the SDK does, returning the verifier's error rather than panicking on it. + fn prove_and_try_verify( + &self, + transition: &StateTransition, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(self.contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .map(|(_, outcome)| outcome.into_result()) + } + /// Proves the executed transition the way the node answers a wait, and verifies the proof /// the way the SDK does. fn prove_and_verify(&self, transition: &StateTransition) -> StateTransitionProofResult { @@ -560,6 +586,37 @@ async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { ); } +/// A client waiting for its listing may ask for the proof after the listing was filled. Its +/// `ask` still proves the placement after a partial fill: the order id is the transition's +/// alone, and the document holds its terms with less open. A listing filled entirely is gone, +/// so its document no longer proves it. +#[tokio::test] +async fn should_prove_a_listing_after_it_is_partly_filled() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + let StateTransitionProofResult::VerifiedDocuments(documents) = market.prove_and_verify(&place) + else { + panic!("expected the listing's document to be proved"); + }; + let order = TokenOrder::from_document( + documents + .get(&order_id) + .and_then(Option::as_ref) + .expect("expected the proof to hold the listing"), + TokenOrderSide::Ask, + ) + .expect("expected an order"); + assert_eq!((order.amount, order.remaining), (1_000, 600)); + + let fill = market.fill_ask(order_id, 1, 600, 6_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert!(market.prove_and_try_verify(&place).is_err()); +} + #[tokio::test] async fn should_sell_part_of_a_listing_and_then_the_rest() { let mut market = tradeable_market(); 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 925e322eabf..fb97d285ae7 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 @@ -1335,9 +1335,12 @@ impl Drive { Ok((root_hash, VerifiedShieldedNullifiers(statuses))) } } - // The token marketplace keeps no token history: a listing is proven - // by its `ask` document, whose order id follows from the signer and - // the nonce and whose terms are the transition's. + // A listing is proven by its `ask` document. Its order id follows from + // the token, the signer and the nonce, so only this transition creates + // a document with it: one holding the transition's terms proves the + // placement even after fills, which leave `remaining` lower (from the + // amount down to 1) and change nothing else. A listing filled entirely + // is gone, and its document no longer proves the placement. TokenTransition::PlaceAsk(token_place_ask_transition) => { let order_id = TokenOrderSide::Ask.order_id( token_id, @@ -1363,8 +1366,10 @@ impl Drive { || order.maker_id != owner_id || order.price != token_place_ask_transition.price() || order.amount != token_place_ask_transition.amount() - // A listing just placed has nothing filled yet. - || order.remaining != token_place_ask_transition.amount() + // Fills lower what is open, never to 0 (a listing filled + // entirely is deleted) nor above what was listed. + || order.remaining == 0 + || order.remaining > order.amount || order.min_fill != token_place_ask_transition.min_fill() || order.expires_at != token_place_ask_transition.expires_at() { From 90918f4787b076799c68c81de09538ff38c92c2a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 11:53:01 +0300 Subject: [PATCH 046/121] 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 047/121] 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 3bcc6565c5e4f31e0b47c4c307a3712e24c196dc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 15:19:31 +0300 Subject: [PATCH 048/121] fix(platform): a listing signs the decimals its price is at A listing's price is per whole token, so the token's decimals set what it is worth, and they were read from the token's configuration when the listing executed: whoever controls the token's conventions could change them between the seller signing and execution and reprice the listing (1,000 base units at 1,000,000,000 credits per whole token are 10,000 credits at 8 decimals, 1 credit at 16). - TokenPlaceAskTransitionV0 gains a signed `decimals` (constructor, wasm-dpp2 wrapper with a required option and checked setter). - The listing's structure validation (batch advanced structure, so check_tx too) refuses it with the new TokenOrderDecimalsMismatchError (40758) unless they are the token's. The order records them, and the placement proof compares them. - Tests: the attack, by a holder other than the conventions controller, is refused; fills of a frozen maker's listing and by a frozen buyer (unless the token allows transfers to frozen balances) are refused and the frozen maker can cancel; the sell-out proof failure is IncorrectProof. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 10 +- packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../src/errors/consensus/state/state_error.rs | 13 +- .../src/errors/consensus/state/token/mod.rs | 2 + .../token_order_decimals_mismatch_error.rs | 66 +++++ .../token_place_ask_transition/v0/mod.rs | 8 +- .../v0/v0_methods.rs | 13 + .../token_place_ask_transition/v0_methods.rs | 12 + .../document/batch_transition/methods/mod.rs | 2 + .../batch_transition/methods/v1/mod.rs | 1 + .../batch_transition/v1/v1_methods.rs | 2 + .../structure_v0/mod.rs | 23 +- .../batch/tests/token/marketplace/mod.rs | 271 +++++++++++++++++- .../token_place_ask_transition_action/mod.rs | 6 + .../v0/mod.rs | 10 + .../v0/transformer.rs | 14 +- .../v0/mod.rs | 1 + .../rs-platform-version/src/version/v15.rs | 5 +- .../src/errors/consensus/consensus_error.rs | 9 +- .../token_transitions/token_place_ask.rs | 19 +- .../tests/unit/TokensTransitions.spec.ts | 24 ++ 21 files changed, 487 insertions(+), 25 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index fd53ec273e0..ba5cf84b8bc 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -68,8 +68,11 @@ 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 +An order's price is in credits per whole token, so the token's decimals set what it is worth. Its +signer signs the decimals the price is at (`decimals`), and the order is refused +(`TokenOrderDecimalsMismatchError`, 40758) unless they are the token's when it executes: a change of +the token's decimals between signing and execution, by whoever controls its conventions, cannot +reprice it. Each order records those decimals (`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 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 @@ -99,7 +102,7 @@ Three token transitions of a batch work with listings. Each is refused before pr | Transition | Fields | Effect | |---|---|---| -| `TokenPlaceAsk` | `amount`, `price`, `minFill`, optional `expiresAt` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | +| `TokenPlaceAsk` | `amount`, `price`, `decimals`, `minFill`, optional `expiresAt` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | | `TokenFillAsk` | `orderId`, `minAmount`, `maxAmount`, `maxTotalPrice` | Moves part or all of the listing from escrow to the buyer, and its cost from the buyer's credits to the seller's | | `TokenCancelOrder` | `orderId`, `side` | Returns what an order holds to its owner and deletes the order | @@ -136,6 +139,7 @@ The rounding favours the seller and is never more than one credit per fill. | A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | | The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` | | The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | +| A listing's `decimals` are the token's | `TokenOrderDecimalsMismatchError` (40758) | | The order is open, for this token | `TokenOrderNotFoundError` (40751) | | A listing's `expiresAt` is after the block, and a fill comes before it | `TokenOrderExpiredError` (40752) | | The fill range meets the listing | `TokenOrderFillAmountError` (40753) | diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 761e966d663..5a302972f90 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -399,6 +399,7 @@ impl ErrorWithCode for StateError { Self::TokenOrderNotOwnedError(_) => 40755, Self::TokenOrderSelfFillError(_) => 40756, Self::TokenOrderValueTooLargeError(_) => 40757, + Self::TokenOrderDecimalsMismatchError(_) => 40758, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index f3789edb6b3..390fdd58656 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -5,7 +5,7 @@ use platform_serialization_derive::{ }; use thiserror::Error; -use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderValueTooLargeError}; +use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderValueTooLargeError, TokenOrderDecimalsMismatchError}; use crate::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use crate::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use crate::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -778,6 +778,10 @@ pub enum StateError { // The token marketplace (protocol version 15). #[error(transparent)] TokenOrderValueTooLargeError(TokenOrderValueTooLargeError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderDecimalsMismatchError(TokenOrderDecimalsMismatchError), } impl From for ConsensusError { @@ -1768,5 +1772,12 @@ mod tests { )), 183 ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderDecimalsMismatchError( + TokenOrderDecimalsMismatchError::new(Identifier::from([1; 32]), 8, 2) + )), + 184 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index bf45ef60251..3a62a28491a 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -83,3 +83,5 @@ mod token_order_self_fill_error; pub use token_order_self_fill_error::*; mod token_order_value_too_large_error; pub use token_order_value_too_large_error::*; +mod token_order_decimals_mismatch_error; +pub use token_order_decimals_mismatch_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs new file mode 100644 index 00000000000..35bd4efb86e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs @@ -0,0 +1,66 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A token order was signed at a price scale (the token's decimals) other than the token's when +/// it executes. An order's price is per whole token, so the decimals set what it is worth: the +/// order is refused rather than priced at a scale its signer did not sign. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A token order for token {token_id} was signed at {signed_decimals} decimals, but the token has {token_decimals}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderDecimalsMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, + signed_decimals: u8, + token_decimals: u8, +} + +impl TokenOrderDecimalsMismatchError { + pub fn new(token_id: Identifier, signed_decimals: u8, token_decimals: u8) -> Self { + Self { + token_id, + signed_decimals, + token_decimals, + } + } + + pub fn token_id(&self) -> Identifier { + self.token_id + } + + pub fn signed_decimals(&self) -> u8 { + self.signed_decimals + } + + pub fn token_decimals(&self) -> u8 { + self.token_decimals + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderDecimalsMismatchError) -> Self { + Self::StateError(StateError::TokenOrderDecimalsMismatchError(err)) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs index f3127cd1afb..c97947f36f2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs @@ -31,6 +31,10 @@ pub struct TokenPlaceAskTransitionV0 { pub amount: TokenAmount, /// The credits asked per whole token (10 to the power of the token's decimals base units). pub price: Credits, + /// The token's decimals the price is at. A listing executes only while they are the token's: + /// the price is per whole token, so a change of the token's decimals between signing and + /// execution would otherwise reprice it. + pub decimals: u8, /// 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. Equal to `amount` for a listing /// that is sold all at once or not at all. @@ -44,8 +48,8 @@ impl fmt::Display for TokenPlaceAskTransitionV0 { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "Token PlaceAsk, base: {}, amount: {}, price: {}, min fill: {}, expires at: {:?}", - self.base, self.amount, self.price, self.min_fill, self.expires_at + "Token PlaceAsk, base: {}, amount: {}, price: {} at {} decimals, min fill: {}, expires at: {:?}", + self.base, self.amount, self.price, self.decimals, self.min_fill, self.expires_at ) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs index 3b3a91b4519..4f20dc5fde2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs @@ -28,6 +28,11 @@ pub trait TokenPlaceAskTransitionV0Methods: TokenBaseTransitionAccessors { fn set_price(&mut self, price: Credits); + /// The token's decimals the price is at + fn decimals(&self) -> u8; + + fn set_decimals(&mut self, decimals: u8); + fn min_fill(&self) -> TokenAmount; fn set_min_fill(&mut self, min_fill: TokenAmount); @@ -54,6 +59,14 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { self.price = price; } + fn decimals(&self) -> u8 { + self.decimals + } + + fn set_decimals(&mut self, decimals: u8) { + self.decimals = decimals; + } + fn min_fill(&self) -> TokenAmount { self.min_fill } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs index b60063205fa..a1199212f44 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs @@ -51,6 +51,18 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransition { } } + fn decimals(&self) -> u8 { + match self { + TokenPlaceAskTransition::V0(v0) => v0.decimals(), + } + } + + fn set_decimals(&mut self, decimals: u8) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_decimals(decimals), + } + } + fn min_fill(&self) -> TokenAmount { match self { TokenPlaceAskTransition::V0(v0) => v0.min_fill(), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 9d3ad6d22c4..72d9eb6084e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -1584,6 +1584,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { token_contract_position: u16, amount: TokenAmount, price: Credits, + decimals: u8, min_fill: TokenAmount, expires_at: Option, identity_public_key: &IdentityPublicKey, @@ -1616,6 +1617,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { token_contract_position, amount, price, + decimals, min_fill, expires_at, identity_public_key, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs index b2508b3ce2b..6eb0df93fd4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs @@ -599,6 +599,7 @@ pub trait DocumentsBatchTransitionMethodsV1: DocumentsBatchTransitionAccessorsV0 token_contract_position: u16, amount: TokenAmount, price: Credits, + decimals: u8, min_fill: TokenAmount, expires_at: Option, identity_public_key: &IdentityPublicKey, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 9d6aee1d216..7c44d8bb685 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -1504,6 +1504,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { token_contract_position: u16, amount: TokenAmount, price: Credits, + decimals: u8, min_fill: TokenAmount, expires_at: Option, identity_public_key: &IdentityPublicKey, @@ -1523,6 +1524,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { }), amount, price, + decimals, min_fill, expires_at, }); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs index 8183a7bc19a..c36fc4b7c41 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -1,5 +1,7 @@ use dpp::block::block_info::BlockInfo; -use dpp::consensus::state::token::{TokenOrderExpiredError, TokenOrderValueTooLargeError}; +use dpp::consensus::state::token::{ + TokenOrderDecimalsMismatchError, TokenOrderExpiredError, TokenOrderValueTooLargeError, +}; 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::tokens::token_order::TokenOrder; @@ -22,8 +24,10 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch) impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransitionAction { /// What a listing needs that reads nothing beyond the contract the action carries and the - /// block: a tradeable token, a total that fits in an order (`i64::MAX` credits) at the - /// token's decimals, and, when it expires, an expiry after the block. + /// block: a tradeable token, the decimals it was signed at being the token's (its price is + /// per whole token, so a change of the token's decimals between signing and execution would + /// reprice it), a total that fits in an order (`i64::MAX` credits) at them, and, when it + /// expires, an expiry after the block. fn validate_structure_v0( &self, block_info: &BlockInfo, @@ -34,7 +38,18 @@ impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransit return Ok(validation_result); } - let decimals = self.base().token_configuration()?.conventions().decimals(); + let token_decimals = self.base().token_configuration()?.conventions().decimals(); + let decimals = self.decimals(); + if decimals != token_decimals { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenOrderDecimalsMismatchError::new( + self.base().token_id(), + decimals, + token_decimals, + ) + .into(), + )); + } if TokenOrder::order_value(self.amount(), self.price(), decimals).is_none() { return Ok(SimpleConsensusValidationResult::new_with_error( TokenOrderValueTooLargeError::new(self.amount(), self.price(), decimals).into(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 6d46793120a..ec551570e05 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -10,7 +10,9 @@ use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; use dpp::data_contract::accessors::v1::DataContractV1Getters; -use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Setters; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::{ + TokenConfigurationConventionV0Getters, TokenConfigurationConventionV0Setters, +}; use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; use dpp::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Setters; use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; @@ -26,6 +28,8 @@ use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use drive::drive::Drive; +use drive::error::proof::ProofError; +use drive::error::Error as DriveError; use simple_signer::signer::SimpleSigner; use std::sync::Arc; @@ -218,6 +222,7 @@ impl Market { expires_at: Option, ) -> (StateTransition, Identifier) { let platform_version = PlatformVersion::latest(); + let decimals = self.token_decimals(); let nonce = self.seller.take_nonce(); let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.seller.id(), nonce); let transition = BatchTransition::new_token_place_ask_transition( @@ -227,6 +232,7 @@ impl Market { 0, amount, price, + decimals, min_fill, expires_at, &self.seller.key, @@ -346,6 +352,132 @@ impl Market { } /// The seller, the contract owner, changes the token's decimals. + /// The seller transfers `amount` to the buyer. + async fn transfer_to_buyer(&mut self, amount: u64) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transfer = BatchTransition::new_token_transfer_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + amount, + self.buyer.id(), + None, + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the transfer"); + self.assert_executes(&transfer, &BlockInfo::default()); + } + + /// The seller, the contract owner, freezes the buyer's token account. + async fn freeze_the_buyer(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let freeze = BatchTransition::new_token_freeze_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + self.buyer.id(), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the freeze"); + self.assert_executes(&freeze, &BlockInfo::default()); + } + + /// A listing by the buyer at `decimals`, and its order id. + async fn buyer_places_ask( + &mut self, + amount: u64, + price: Credits, + decimals: u8, + ) -> (StateTransition, Identifier) { + let platform_version = PlatformVersion::latest(); + let nonce = self.buyer.take_nonce(); + let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); + let transition = BatchTransition::new_token_place_ask_transition( + self.token_id, + self.buyer.id(), + self.contract.id(), + 0, + amount, + price, + decimals, + 1, + None, + &self.buyer.key, + nonce, + 0, + &self.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing"); + (transition, order_id) + } + + /// A fill by the seller of up to `max_amount` of the listing `order_id`. + async fn seller_fills_ask(&mut self, order_id: Identifier, max_amount: u64) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + order_id, + 1, + max_amount, + u64::MAX >> 1, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + /// The token's decimals as stored now, which a placement signs. + fn token_decimals(&self) -> u8 { + let platform_version = PlatformVersion::latest(); + self.platform + .drive + .get_contract_with_fetch_info( + self.contract.id().to_buffer(), + false, + None, + platform_version, + ) + .expect("expected to fetch the token's contract") + .expect("expected the token's contract") + .contract + .expected_token_configuration(0) + .expect("expected the token") + .conventions() + .decimals() + } + async fn set_decimals(&mut self, decimals: u8) { let platform_version = PlatformVersion::latest(); let mut conventions = self @@ -393,6 +525,7 @@ impl Market { position, 1_000, PRICE, + 8, 1, None, &self.seller.key, @@ -495,7 +628,7 @@ impl Market { fn prove_and_try_verify( &self, transition: &StateTransition, - ) -> Result { + ) -> Result { let platform_version = PlatformVersion::latest(); let proof = self .platform @@ -614,7 +747,10 @@ async fn should_prove_a_listing_after_it_is_partly_filled() { let fill = market.fill_ask(order_id, 1, 600, 6_000).await; market.assert_executes(&fill, &BlockInfo::default()); - assert!(market.prove_and_try_verify(&place).is_err()); + assert_matches!( + market.prove_and_try_verify(&place), + Err(DriveError::Proof(ProofError::IncorrectProof(_))) + ); } #[tokio::test] @@ -750,6 +886,135 @@ async fn should_refuse_listing_a_token_that_is_not_tradeable() { assert_eq!(market.listing(order_id), None); } +/// The contract owner (the seller) may change the token's conventions and freeze accounts. +fn make_token_tradeable_with_owner_conventions_and_freezes( + token_configuration: &mut TokenConfiguration, +) { + make_token_tradeable(token_configuration); + let owner_rules = ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }); + token_configuration.set_conventions_change_rules(owner_rules.clone()); + token_configuration.set_freeze_rules(owner_rules); +} + +/// A listing's price is per whole token, so the decimals set what it is worth: 1,000 base +/// units at 1,000,000,000 credits per whole token are 10,000 credits at 8 decimals and 1 credit +/// at 16. A holder signs a listing at the token's 8 decimals; the identity controlling the +/// token's conventions (the contract owner, not the holder) changes them to 16 before the +/// listing executes. The listing is refused with `TokenOrderDecimalsMismatchError`, by check_tx +/// and in a block, and nothing moves into escrow. +#[tokio::test] +async fn should_refuse_a_listing_signed_at_other_decimals() { + let mut market = market_with( + make_token_tradeable_with_owner_conventions_and_freezes, + dash_to_credits!(1.0), + ); + market.transfer_to_buyer(5_000).await; + let (listing, order_id) = market.buyer_places_ask(1_000, 1_000_000_000, 8).await; + + market.set_decimals(16).await; + + assert_matches!( + market.check_tx(&listing).as_slice(), + [ConsensusError::StateError(StateError::TokenOrderDecimalsMismatchError(error))] + if error.signed_decimals() == 8 && error.token_decimals() == 16 + ); + assert_matches!( + market.process(&listing).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError( + StateError::TokenOrderDecimalsMismatchError(inner) + ), + .. + }] if inner.token_id() == market.token_id && error.code() == 40758 + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); +} + +/// A listing whose maker's account is frozen cannot be filled: the fill is refused with the +/// maker's `IdentityTokenAccountFrozenError` and nothing moves. The frozen maker can still +/// cancel it, and gets back what it holds in escrow. +#[tokio::test] +async fn should_refuse_filling_a_frozen_makers_listing_and_let_the_maker_cancel() { + let mut market = market_with( + make_token_tradeable_with_owner_conventions_and_freezes, + dash_to_credits!(1.0), + ); + market.transfer_to_buyer(5_000).await; + let (place, order_id) = market.buyer_places_ask(1_000, PRICE, 8).await; + market.assert_executes(&place, &BlockInfo::default()); + market.freeze_the_buyer().await; + + let fill = market.seller_fills_ask(order_id, 400).await; + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError(error)), + .. + }] if *error.identity_id() == market.buyer.id() && *error.token_id() == market.token_id + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + assert_eq!(market.token_balance(market.buyer.id()), Some(4_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 5_000) + ); + + let cancel = market.cancel(order_id, true).await; + market.assert_executes(&cancel, &BlockInfo::default()); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); + assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); +} + +/// A buyer whose account is frozen cannot buy a listing unless the token allows transfers to +/// frozen balances: then the fill executes and the tokens land in the frozen balance. +#[tokio::test] +async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to_frozen_balances() { + for allowed in [false, true] { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable_with_owner_conventions_and_freezes(token_configuration); + token_configuration.allow_transfer_to_frozen_balance(allowed); + }, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + market.freeze_the_buyer().await; + + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + let processing_result = market.process(&fill); + if allowed { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(400)); + assert_eq!(market.escrow(order_id), Some(600)); + } else { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityTokenAccountFrozenError(error) + ), + .. + }] if *error.identity_id() == market.buyer.id() + ); + assert_eq!(market.token_balance(market.buyer.id()), None); + assert_eq!(market.escrow(order_id), Some(1_000)); + } + } +} + #[tokio::test] async fn should_refuse_listing_more_than_the_seller_holds() { let mut market = tradeable_market(); diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs index 92243e33f03..5f6973b3a70 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs @@ -65,6 +65,12 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionAction } } + fn decimals(&self) -> u8 { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.decimals, + } + } + fn order_document(&self) -> &Document { match self { TokenPlaceAskTransitionAction::V0(v0) => &v0.order_document, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs index 57f8aafd032..a985f8034f8 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs @@ -24,6 +24,9 @@ pub struct TokenPlaceAskTransitionActionV0 { pub min_fill: TokenAmount, /// The block time from which the listing can no longer be filled pub expires_at: Option, + /// The token's decimals the listing was signed at, which its price is at and its order + /// records; the structure validation refuses them unless they are the token's + pub decimals: u8, /// The listing's `ask` document, stamped with the block that places it pub order_document: Document, /// The token market contract, whose `ask` document records the listing @@ -53,6 +56,9 @@ pub trait TokenPlaceAskTransitionActionAccessorsV0 { /// The block time from which the listing can no longer be filled fn expires_at(&self) -> Option; + /// The token's decimals the listing was signed at + fn decimals(&self) -> u8; + /// The listing's `ask` document fn order_document(&self) -> &Document; @@ -104,6 +110,10 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionActionV self.expires_at } + fn decimals(&self) -> u8 { + self.decimals + } + fn order_document(&self) -> &Document { &self.order_document } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index 703594394db..26a30b2f97b 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -8,8 +8,6 @@ use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use dpp::ProtocolError; use dpp::consensus::ConsensusError; -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::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use platform_version::version::PlatformVersion; @@ -51,6 +49,7 @@ impl TokenPlaceAskTransitionActionV0 { base, amount, price, + decimals, min_fill, expires_at, } = value; @@ -99,10 +98,12 @@ impl TokenPlaceAskTransitionActionV0 { false => return bump(base_action_validation_result.errors, fee_result), }; - let decimals = match base_action.token_configuration_or_position_error() { - Ok(token_configuration) => token_configuration.conventions().decimals(), - Err(error) => return bump(vec![error], fee_result), - }; + if let Err(error) = base_action.token_configuration_or_position_error() { + return bump(vec![error], fee_result); + } + // The listing is priced at the decimals it was signed at: the structure validation refuses + // it unless they are the token's. + let decimals = *decimals; let order_id = TokenOrderSide::Ask.order_id(base.token_id(), owner_id, base.identity_contract_nonce()); @@ -130,6 +131,7 @@ impl TokenPlaceAskTransitionActionV0 { price: *price, min_fill: *min_fill, expires_at: *expires_at, + decimals, order_document, token_market_contract: drive .token_market_contract_fetch_info(platform_version)?, 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 fb97d285ae7..b825f4591a0 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 @@ -1365,6 +1365,7 @@ impl Drive { if order.token_id != token_id || order.maker_id != owner_id || order.price != token_place_ask_transition.price() + || order.decimals != token_place_ask_transition.decimals() || order.amount != token_place_ask_transition.amount() // Fills lower what is open, never to 0 (a listing filled // entirely is deleted) nor above what was listed. diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 32a0c65ea5f..b7cf6007289 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -168,8 +168,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// contract and the block (a tradeable token, a listing's value and expiry) run with the batch /// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. /// A listing's terms, its expiry included (at most `i64::MAX`, `InvalidTokenOrderExpiryError` -/// 10464), are checked with the transition's structure, unpaid. A listing records the token's -/// decimals and its fills are priced at them; a listing of a token its contract does not +/// 10464), are checked with the transition's structure, unpaid. A listing signs the decimals +/// its price is at and is refused unless they are the token's (`TokenOrderDecimalsMismatchError` +/// 40758, with the batch `advanced_structure`); it records them and its fills are priced at them; a listing of a token its contract does not /// define bumps the nonce with the token base rules' error. Their structure and state /// validations and their high-level operation converters are 0 in every table, which only this /// version reaches. diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index cfa2a13a48f..2a4866d8c67 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -10,9 +10,9 @@ use dpp::consensus::basic::token::{ InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, }; use dpp::consensus::state::token::{ - TokenNotTradeableError, TokenOrderExpiredError, TokenOrderFillAmountError, - TokenOrderNotFoundError, TokenOrderNotOwnedError, TokenOrderPriceLimitError, - TokenOrderSelfFillError, TokenOrderValueTooLargeError, + TokenNotTradeableError, TokenOrderDecimalsMismatchError, TokenOrderExpiredError, + TokenOrderFillAmountError, TokenOrderNotFoundError, TokenOrderNotOwnedError, + TokenOrderPriceLimitError, TokenOrderSelfFillError, TokenOrderValueTooLargeError, }; use dpp::consensus::ConsensusError as DPPConsensusError; @@ -559,6 +559,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenOrderValueTooLargeError(e) => { generic_consensus_error!(TokenOrderValueTooLargeError, e).into() } + StateError::TokenOrderDecimalsMismatchError(e) => { + generic_consensus_error!(TokenOrderDecimalsMismatchError, e).into() + } StateError::IdentityInTokenConfigurationNotFoundError(e) => { generic_consensus_error!(IdentityInTokenConfigurationNotFoundError, e).into() } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs index 99eff49c0fd..65949f6341f 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -2,7 +2,7 @@ use crate::error::WasmDppResult; use crate::impl_try_from_js_value; use crate::impl_wasm_type_info; use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; -use crate::utils::{try_from_options, try_from_options_optional_with, try_from_options_with, try_to_u64}; +use crate::utils::{try_from_options, try_from_options_optional_with, try_from_options_with, try_to_u64, try_to_u8}; use dpp::balances::credits::TokenAmount; use dpp::fee::Credits; use dpp::prelude::TimestampMillis; @@ -23,6 +23,8 @@ export interface TokenPlaceAskTransitionOptions { base: TokenBaseTransition; amount: bigint; price: bigint; + /** The token's decimals the price is at; the listing is refused unless they are the token's. */ + decimals: number; minFill: bigint; expiresAt?: bigint; } @@ -63,6 +65,9 @@ impl TokenPlaceAskTransitionWasm { let price: Credits = try_from_options_with(&options, "price", |v| try_to_u64(v, "price"))?; + let decimals: u8 = + try_from_options_with(&options, "decimals", |v| try_to_u8(v, "decimals"))?; + let min_fill: TokenAmount = try_from_options_with(&options, "minFill", |v| try_to_u64(v, "minFill"))?; @@ -74,6 +79,7 @@ impl TokenPlaceAskTransitionWasm { base: base.into(), amount, price, + decimals, min_fill, expires_at, }, @@ -95,6 +101,11 @@ impl TokenPlaceAskTransitionWasm { self.0.price() } + #[wasm_bindgen(getter = decimals)] + pub fn decimals(&self) -> u8 { + self.0.decimals() + } + #[wasm_bindgen(getter = minFill)] pub fn min_fill(&self) -> TokenAmount { self.0.min_fill() @@ -124,6 +135,12 @@ impl TokenPlaceAskTransitionWasm { Ok(()) } + #[wasm_bindgen(setter = decimals)] + pub fn set_decimals(&mut self, decimals: &js_sys::Number) -> WasmDppResult<()> { + self.0.set_decimals(try_to_u8(decimals, "decimals")?); + Ok(()) + } + #[wasm_bindgen(setter = minFill)] pub fn set_min_fill( &mut self, diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index edc995528df..63dab9c0e9f 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -788,6 +788,7 @@ describe('TokenTransitions', () => { base: baseTransition, amount: BigInt(1000), price: BigInt(100), + decimals: 8, minFill: BigInt(1), expiresAt: BigInt(5000), }); @@ -804,12 +805,35 @@ describe('TokenTransitions', () => { expect(transition.price).to.equal(BigInt(200)); expect(transition.minFill).to.equal(BigInt(10)); expect(transition.expiresAt).to.equal(BigInt(6000)); + expect(transition.decimals).to.equal(8); + + transition.decimals = 2; + + expect(transition.decimals).to.equal(2); transition.expiresAt = undefined; expect(transition.expiresAt).to.be.undefined(); }); + it('should refuse decimals outside a byte and keep the previous ones', () => { + const transition = listing(); + + [256, -1, 1.5].forEach((value) => { + expect(() => { transition.decimals = value; }).to.throw(); + expect(transition.decimals).to.equal(8); + }); + }); + + it('should refuse a listing without decimals', () => { + expect(() => new wasm.TokenPlaceAskTransition({ + base: baseTransition, + amount: BigInt(1000), + price: BigInt(100), + minFill: BigInt(1), + })).to.throw(); + }); + ['amount', 'price', 'minFill', 'expiresAt'].forEach((term) => { it(`should refuse an out-of-range ${term} and keep the previous one`, () => { const transition = listing(); From 3f9225442b65057cb8c461e86586a716dc61af02 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 15:56:55 +0300 Subject: [PATCH 049/121] 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 050/121] 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 051/121] 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 cd0380fc2725f7a033525c4ea64199be821e5e15 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 20:22:11 +0300 Subject: [PATCH 052/121] fix(drive): refuse a listing placement carrying group information in its proof A placement is never a group action and the node refuses group information on it, but the proof verifier does not run that validation: an executed listing re-sent with group information would verify. The PlaceAsk arm refuses it first. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 43 +++++++++++++++++++ .../v0/mod.rs | 9 ++++ 2 files changed, 52 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index ec551570e05..ad716090f02 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -19,7 +19,11 @@ use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTrad use dpp::data_contract::DataContract; use dpp::document::Document; use dpp::fee::Credits; +use dpp::group::GroupStateTransitionInfo; use dpp::prelude::{Identity, IdentityPublicKey}; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use dpp::system_data_contracts::SystemDataContract; @@ -719,6 +723,45 @@ async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { ); } +/// `transition` with group action information on its one token transition, which no executed +/// placement carries: the node refuses it on a placement. +fn with_group_info(transition: &StateTransition) -> StateTransition { + let StateTransition::Batch(BatchTransition::V1(mut batch)) = transition.clone() else { + panic!("expected a batch of token transitions"); + }; + let BatchedTransition::Token(token_transition) = &mut batch.transitions[0] else { + panic!("expected a token transition"); + }; + token_transition + .base_mut() + .set_using_group_info(Some(GroupStateTransitionInfo { + group_contract_position: 0, + action_id: Identifier::from([3; 32]), + action_is_proposer: false, + })); + StateTransition::Batch(BatchTransition::V1(batch)) +} + +/// A placement is never a group action: the node refuses group information on it. A listing +/// that executed, re-sent with group information and otherwise the same, is refused rather +/// than proved by its `ask`; the listing itself still proves. +#[tokio::test] +async fn should_not_prove_a_listing_carrying_group_information() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + assert_matches!( + market.prove_and_try_verify(&with_group_info(&place)), + Err(DriveError::Proof(ProofError::UnexpectedResultProof(_))) + ); + assert_matches!( + market.prove_and_verify(&place), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.get(&order_id).is_some_and(Option::is_some) + ); +} + /// A client waiting for its listing may ask for the proof after the listing was filled. Its /// `ask` still proves the placement after a partial fill: the order id is the transition's /// alone, and the document holds its terms with less open. A listing filled entirely is gone, 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 b825f4591a0..3c73c723f71 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 @@ -1342,6 +1342,15 @@ impl Drive { // amount down to 1) and change nothing else. A listing filled entirely // is gone, and its document no longer proves the placement. TokenTransition::PlaceAsk(token_place_ask_transition) => { + // A placement is never a group action: the node refuses group + // information on it, so a transition carrying some was not + // executed, and its proof must not borrow a group action's + // evidence (which may stand without the placement's record). + if token_transition.base().using_group_info().is_some() { + return Err(Error::Proof(ProofError::UnexpectedResultProof( + "a token listing placement carries group action information, which no executed placement has (token place ask)".to_string(), + ))); + } let order_id = TokenOrderSide::Ask.order_id( token_id, owner_id, From 6bb00b0bb6c1610a9bf2b5657ba5e11880fadb4a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 21:38:22 +0300 Subject: [PATCH 053/121] fix(drive): a marketplace proof binds the token id to its contract and position The proof verifier read a marketplace transition by its token id without checking that the id is the one its contract and position derive, as the node's structure validation requires. The genuine proof of an executed listing verified the same request re-signed naming another position of a two-token contract (or another contract) with the same token id, a request the node refuses. Every marketplace transition (is_marketplace_order) now refuses a token id its contract and position do not derive. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 80 +++++++++++++++++++ .../v0/mod.rs | 17 ++++ 2 files changed, 97 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index ad716090f02..e1c93a496bb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -10,6 +10,7 @@ use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::accessors::v1::DataContractV1Setters; use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::{ TokenConfigurationConventionV0Getters, TokenConfigurationConventionV0Setters, }; @@ -24,6 +25,7 @@ use dpp::prelude::{Identity, IdentityPublicKey}; use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use dpp::system_data_contracts::SystemDataContract; @@ -629,6 +631,34 @@ impl Market { /// Proves the executed transition the way the node answers a wait, and verifies the proof /// the way the SDK does, returning the verifier's error rather than panicking on it. + /// Proves `proved` the way the node answers a wait and verifies that proof against + /// `claimed`, with the verifier's contract lookup answering `contract`. + fn verify_proof_of_as( + &self, + proved: &StateTransition, + claimed: &StateTransition, + contract: &DataContract, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(proved, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + Drive::verify_state_transition_was_executed_with_proof( + claimed, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .map(|(_, outcome)| outcome.into_result()) + } + fn prove_and_try_verify( &self, transition: &StateTransition, @@ -742,6 +772,56 @@ fn with_group_info(transition: &StateTransition) -> StateTransition { StateTransition::Batch(BatchTransition::V1(batch)) } +/// `transition` with the base of its one token transition changed by `change`. +fn with_base( + transition: &StateTransition, + change: impl FnOnce(&mut TokenBaseTransition), +) -> StateTransition { + let StateTransition::Batch(BatchTransition::V1(mut batch)) = transition.clone() else { + panic!("expected a batch of token transitions"); + }; + let BatchedTransition::Token(token_transition) = &mut batch.transitions[0] else { + panic!("expected a token transition"); + }; + change(token_transition.base_mut()); + StateTransition::Batch(BatchTransition::V1(batch)) +} + +/// A marketplace transition's proof is read by its token id, which must be the one its contract +/// and position derive. The genuine proof of an executed listing does not verify the same +/// request re-signed naming another position of a two-token contract, or another contract, +/// with the same token id: the node refuses such a request, so it never executed. +#[tokio::test] +async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_not_derive() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let mut two_tokens = market.contract.clone(); + let token = market + .contract + .expected_token_configuration(0) + .expect("expected the token") + .clone(); + two_tokens.add_token(1, token); + + for claimed in [ + with_base(&place, |base| base.set_token_contract_position(1)), + with_base(&place, |base| { + base.set_data_contract_id(Identifier::from([9; 32])) + }), + ] { + assert_matches!( + market.verify_proof_of_as(&place, &claimed, &two_tokens), + Err(DriveError::Proof(ProofError::UnexpectedResultProof(_))) + ); + } + assert_matches!( + market.verify_proof_of_as(&place, &place, &two_tokens), + Ok(StateTransitionProofResult::VerifiedDocuments(documents)) + if documents.get(&order_id).is_some_and(Option::is_some) + ); +} + /// A placement is never a group action: the node refuses group information on it. A listing /// that executed, re-sent with group information and otherwise the same, is refused rather /// than proved by its `ask`; the listing itself still proves. 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 3c73c723f71..133b71b70d2 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 @@ -624,6 +624,23 @@ impl Drive { let data_contract_id = token_transition.data_contract_id(); let token_id = token_transition.token_id(); + // A marketplace transition's proof is read by its token id, so the id + // must be the one its contract and position derive, as the node's + // structure validation requires: a request naming one token's position + // with another's id never executes, and must not borrow the evidence of + // the one that did. + if token_transition.is_marketplace_order() + && token_id != token_transition.base().calculate_token_id() + { + return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( + "the token marketplace transition names token {} at position {} of contract {}, which derive token {}", + token_id, + token_transition.base().token_contract_position(), + data_contract_id, + token_transition.base().calculate_token_id() + )))); + } + let contract = known_contracts_provider_fn(&data_contract_id)?.ok_or( Error::Proof(ProofError::UnknownContract(format!( "unknown contract with id {} in token verification", From 312761896983df15441a0a76ab3599bdf4a2a56e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 23:06:06 +0300 Subject: [PATCH 054/121] fix(platform): listing review round 4 - The wallet's listing approval names the decimals the listing signs, so listings differing only in them render different approvals. - What an order is worth at the decimals it signs is pure transition data now: the shared order-terms validation (dpp) refuses an order worth more than i64::MAX credits with the new basic error InvalidTokenOrderValueError (10465), unpaid and in check_tx; the advanced-structure copy and the unreleased state error TokenOrderValueTooLargeError (40757) are removed. - The marketplace proof helpers compare the reconstructed root with the committed Drive root. - New test: a fill that leaves the rounded running total unchanged pays 0 credits, still takes its unit from escrow, advances the nonce and charges its fee. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 3 +- .../src/errors/consensus/basic/basic_error.rs | 12 ++ .../token/invalid_token_order_value_error.rs} | 18 +-- .../src/errors/consensus/basic/token/mod.rs | 2 + packages/rs-dpp/src/errors/consensus/codes.rs | 2 +- .../src/errors/consensus/state/state_error.rs | 15 +-- .../src/errors/consensus/state/token/mod.rs | 2 - .../batched_transition/token_order_terms.rs | 37 ++++++- .../validate_structure/v0/mod.rs | 1 + .../structure_v0/mod.rs | 14 +-- .../batch/tests/token/marketplace/mod.rs | 104 ++++++++++++++++-- .../rs-platform-version/src/version/v15.rs | 10 +- .../network/state_transition_summary.rs | 47 +++++++- .../src/errors/consensus/consensus_error.rs | 9 +- 14 files changed, 211 insertions(+), 65 deletions(-) rename packages/rs-dpp/src/errors/consensus/{state/token/token_order_value_too_large_error.rs => basic/token/invalid_token_order_value_error.rs} (75%) diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index ba5cf84b8bc..32b5e07f7ca 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -135,7 +135,7 @@ The rounding favours the seller and is never more than one credit per fill. | Rule | Refused with | |---|---| -| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, `expiresAt` at most `i64::MAX` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderExpiryError` (10464) | +| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, `expiresAt` at most `i64::MAX`, and what the order is worth at the `decimals` it signs at most `i64::MAX` credits | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderExpiryError` (10464), `InvalidTokenOrderValueError` (10465) | | A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | | The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` | | The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | @@ -146,7 +146,6 @@ The rounding favours the seller and is never more than one credit per fill. | The fill costs at most `maxTotalPrice` | `TokenOrderPriceLimitError` (40754) | | Only the order's owner cancels it | `TokenOrderNotOwnedError` (40755) | | A seller does not fill their own listing | `TokenOrderSelfFillError` (40756) | -| A listing is worth at most `i64::MAX` credits | `TokenOrderValueTooLargeError` (40757) | | The seller holds `amount` | `IdentityDoesNotHaveEnoughTokenBalanceError` | | The token is not paused (to place or fill) | `TokenIsPausedError` | | The seller's account is not frozen (to place or fill), nor the buyer's unless the token allows transfers to frozen balances | `IdentityTokenAccountFrozenError` | 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 86f9e39118f..818d9c07f3b 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -113,6 +113,7 @@ use crate::consensus::basic::state_transition::{ }; use crate::consensus::basic::token::{ InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, + InvalidTokenOrderValueError, }; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, @@ -884,6 +885,10 @@ pub enum BasicError { // A token order's structure (protocol version 15). #[error(transparent)] InvalidTokenOrderExpiryError(InvalidTokenOrderExpiryError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderValueError(InvalidTokenOrderValueError), } impl From for ConsensusError { @@ -1121,6 +1126,13 @@ mod tests { )), 209 ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderValueError( + InvalidTokenOrderValueError::new(u64::MAX, u64::MAX, 0) + )), + 210 + ); } /// 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/state/token/token_order_value_too_large_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs similarity index 75% rename from packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs rename to packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs index 9dcd4fa6e8a..97c99efd3c4 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/token_order_value_too_large_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs @@ -1,5 +1,5 @@ use crate::balances::credits::TokenAmount; -use crate::consensus::state::state_error::StateError; +use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::fee::Credits; use crate::ProtocolError; @@ -9,8 +9,8 @@ use platform_serialization_derive::{ }; use thiserror::Error; -/// What a token order is worth in credits, its amount at its price, does not fit in what Platform -/// can hold. +/// What a token order is worth in credits, its amount at its price and the decimals it signs, +/// does not fit in what an order can hold (`i64::MAX` credits). #[derive( Error, Debug, @@ -24,9 +24,9 @@ use thiserror::Error; PlatformDeserializeUntrusted, DecodeUntrusted, )] -#[error("A token order of {amount} base units at {price} credits per whole token ({decimals} decimals) is worth more credits than Platform can hold")] +#[error("A token order of {amount} base units at {price} credits per whole token ({decimals} decimals) is worth more credits than an order can hold")] #[platform_serialize(unversioned)] -pub struct TokenOrderValueTooLargeError { +pub struct InvalidTokenOrderValueError { /* DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION @@ -37,7 +37,7 @@ pub struct TokenOrderValueTooLargeError { decimals: u8, } -impl TokenOrderValueTooLargeError { +impl InvalidTokenOrderValueError { pub fn new(amount: TokenAmount, price: Credits, decimals: u8) -> Self { Self { amount, @@ -59,8 +59,8 @@ impl TokenOrderValueTooLargeError { } } -impl From for ConsensusError { - fn from(err: TokenOrderValueTooLargeError) -> Self { - Self::StateError(StateError::TokenOrderValueTooLargeError(err)) +impl From for ConsensusError { + fn from(err: InvalidTokenOrderValueError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderValueError(err)) } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index b9ea2bc301e..d8f77191335 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -41,3 +41,5 @@ mod invalid_token_order_fill_range_error; pub use invalid_token_order_fill_range_error::*; mod invalid_token_order_expiry_error; pub use invalid_token_order_expiry_error::*; +mod invalid_token_order_value_error; +pub use invalid_token_order_value_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 5a302972f90..03e736a3045 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -129,6 +129,7 @@ impl ErrorWithCode for BasicError { Self::InvalidTokenOrderPriceError(_) => 10462, Self::InvalidTokenOrderFillRangeError(_) => 10463, Self::InvalidTokenOrderExpiryError(_) => 10464, + Self::InvalidTokenOrderValueError(_) => 10465, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, @@ -398,7 +399,6 @@ impl ErrorWithCode for StateError { Self::TokenOrderPriceLimitError(_) => 40754, Self::TokenOrderNotOwnedError(_) => 40755, Self::TokenOrderSelfFillError(_) => 40756, - Self::TokenOrderValueTooLargeError(_) => 40757, Self::TokenOrderDecimalsMismatchError(_) => 40758, // Identity Errors: 40200-40299 diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 390fdd58656..706da804e05 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -5,7 +5,7 @@ use platform_serialization_derive::{ }; use thiserror::Error; -use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderValueTooLargeError, TokenOrderDecimalsMismatchError}; +use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderDecimalsMismatchError}; use crate::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use crate::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use crate::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -775,10 +775,6 @@ pub enum StateError { #[error(transparent)] TokenOrderSelfFillError(TokenOrderSelfFillError), - // The token marketplace (protocol version 15). - #[error(transparent)] - TokenOrderValueTooLargeError(TokenOrderValueTooLargeError), - // The token marketplace (protocol version 15). #[error(transparent)] TokenOrderDecimalsMismatchError(TokenOrderDecimalsMismatchError), @@ -1766,18 +1762,11 @@ mod tests { 182 ); // The token marketplace (protocol version 15). - assert_eq!( - discriminant_of(StateError::TokenOrderValueTooLargeError( - TokenOrderValueTooLargeError::new(u64::MAX, u64::MAX, 0) - )), - 183 - ); - // The token marketplace (protocol version 15). assert_eq!( discriminant_of(StateError::TokenOrderDecimalsMismatchError( TokenOrderDecimalsMismatchError::new(Identifier::from([1; 32]), 8, 2) )), - 184 + 183 ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index 3a62a28491a..9feb894131c 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -81,7 +81,5 @@ mod token_order_not_owned_error; pub use token_order_not_owned_error::*; mod token_order_self_fill_error; pub use token_order_self_fill_error::*; -mod token_order_value_too_large_error; -pub use token_order_value_too_large_error::*; mod token_order_decimals_mismatch_error; pub use token_order_decimals_mismatch_error::*; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs index ddb36d93277..d792302734d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs @@ -4,12 +4,13 @@ use crate::balances::credits::TokenAmount; use crate::consensus::basic::token::{ InvalidTokenAmountError, InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, - InvalidTokenOrderPriceError, + InvalidTokenOrderPriceError, InvalidTokenOrderValueError, }; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::fee::Credits; use crate::prelude::TimestampMillis; +use crate::tokens::token_order::TokenOrder; use crate::validation::SimpleConsensusValidationResult; /// The largest amount, price and expiry an order may carry: its document stores each as a signed @@ -17,12 +18,14 @@ use crate::validation::SimpleConsensusValidationResult; pub(crate) const MAX_TOKEN_ORDER_TERM: u64 = i64::MAX as u64; /// The amount and the price are between 1 and `i64::MAX`, the minimum fill between 1 and the -/// amount, and an expiry at most `i64::MAX`. Whether the whole order's value fits and whether it -/// expires after the block are checked with the batch's advanced structure, which knows the -/// token's decimals and the block. +/// amount, an expiry at most `i64::MAX`, and what the order is worth at the decimals it signs +/// (`TokenOrder::order_value`) at most `i64::MAX` credits. Whether the signed decimals are the +/// token's and whether it expires after the block are checked with the batch's advanced +/// structure, which knows the token and the block. pub(crate) fn validate_token_order_terms( amount: TokenAmount, price: Credits, + decimals: u8, min_fill: TokenAmount, expires_at: Option, ) -> SimpleConsensusValidationResult { @@ -51,6 +54,11 @@ pub(crate) fn validate_token_order_terms( ); } } + if TokenOrder::order_value(amount, price, decimals).is_none() { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderValueError::new(amount, price, decimals).into(), + ); + } SimpleConsensusValidationResult::default() } @@ -64,7 +72,26 @@ mod tests { min_fill: TokenAmount, expires_at: Option, ) -> Vec { - validate_token_order_terms(amount, price, min_fill, expires_at).errors + validate_token_order_terms(amount, price, 8, min_fill, expires_at).errors + } + + /// An order is worth its amount at its price at the decimals it signs, at most `i64::MAX` + /// credits: 1 base unit at `i64::MAX` credits per whole token is worth exactly that at 0 + /// decimals, 2 are worth more; at 8 decimals 2 are worth far less. + #[test] + fn should_refuse_an_order_worth_more_than_an_order_holds() { + let at = |amount, decimals| { + validate_token_order_terms(amount, MAX_TOKEN_ORDER_TERM, decimals, 1, None).errors + }; + assert!(at(1, 0).is_empty()); + assert!(at(2, 8).is_empty()); + assert!(matches!( + at(2, 0).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] + if error.amount() == 2 + && error.price() == MAX_TOKEN_ORDER_TERM + && error.decimals() == 0 + )); } #[test] diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs index 1ab3097b572..f9d1ea9e487 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -14,6 +14,7 @@ impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { Ok(validate_token_order_terms( self.amount(), self.price(), + self.decimals(), self.min_fill(), self.expires_at(), )) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs index c36fc4b7c41..6b0f3e70f5f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -1,10 +1,7 @@ use dpp::block::block_info::BlockInfo; -use dpp::consensus::state::token::{ - TokenOrderDecimalsMismatchError, TokenOrderExpiredError, TokenOrderValueTooLargeError, -}; +use dpp::consensus::state::token::{TokenOrderDecimalsMismatchError, TokenOrderExpiredError}; 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::tokens::token_order::TokenOrder; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; @@ -26,8 +23,8 @@ impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransit /// What a listing needs that reads nothing beyond the contract the action carries and the /// block: a tradeable token, the decimals it was signed at being the token's (its price is /// per whole token, so a change of the token's decimals between signing and execution would - /// reprice it), a total that fits in an order (`i64::MAX` credits) at them, and, when it - /// expires, an expiry after the block. + /// reprice it), and, when it expires, an expiry after the block. What the listing is worth + /// at the decimals it signs is checked with the transition's structure. fn validate_structure_v0( &self, block_info: &BlockInfo, @@ -50,11 +47,6 @@ impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransit .into(), )); } - if TokenOrder::order_value(self.amount(), self.price(), decimals).is_none() { - return Ok(SimpleConsensusValidationResult::new_with_error( - TokenOrderValueTooLargeError::new(self.amount(), self.price(), decimals).into(), - )); - } if let Some(expires_at) = self.expires_at() { if expires_at <= block_info.time_ms { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index e1c93a496bb..4d7d933ff6d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -656,7 +656,14 @@ impl Market { &lookup, platform_version, ) - .map(|(_, outcome)| outcome.into_result()) + .map(|(root_hash, outcome)| { + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); + outcome.into_result() + }) } fn prove_and_try_verify( @@ -680,7 +687,14 @@ impl Market { &lookup, platform_version, ) - .map(|(_, outcome)| outcome.into_result()) + .map(|(root_hash, outcome)| { + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); + outcome.into_result() + }) } /// Proves the executed transition the way the node answers a wait, and verifies the proof @@ -704,9 +718,23 @@ impl Market { platform_version, ) .expect("expected the proof to verify"); - assert_ne!(root_hash, [0u8; 32]); + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); outcome.into_result() } + + /// The root of the state committed so far, which a client authenticates a proof against. + fn committed_root(&self) -> [u8; 32] { + self.platform + .drive + .grove + .root_hash(None, &PlatformVersion::latest().drive.grove_version) + .value + .expect("expected the committed Drive root") + } } #[tokio::test] @@ -958,6 +986,54 @@ async fn should_charge_each_fill_the_change_in_the_running_total() { assert_eq!(market.escrow(order_id), Some(1)); } +/// 0.5 credits per base unit: of a listing of 3, the first unit costs 1 credit (its running +/// total, 0.5, rounds up to 1) and the second 0 (the total for 2 units is exactly 1). A fill +/// paying 0 credits, with a limit of 0, still takes its unit out of escrow, advances the buyer's +/// nonce and charges its fee; the seller's credits do not move. The listing keeps one unit open, +/// so no storage refund mixes into the seller's balance. +#[tokio::test] +async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(3, 50_000_000, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 1, 1).await; + market.assert_executes(&fill, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + let buyer_credits = market.credit_balance(market.buyer.id()); + let buyer_nonce = market.contract_nonce(market.buyer.id()); + + let fill = market.fill_ask(order_id, 1, 1, 0).await; + let processing_result = market.process(&fill); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!( + "expected the fill to execute: {:?}", + processing_result.execution_results() + ); + }; + assert!(fee_result.processing_fee > 0); + let refund = fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.buyer.id()) + .unwrap_or_default(); + assert_eq!( + market.credit_balance(market.buyer.id()) as i128, + buyer_credits as i128 - fee_result.storage_fee as i128 - fee_result.processing_fee as i128 + + refund as i128, + "the buyer pays the fee and nothing for the unit" + ); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!(market.token_balance(market.buyer.id()), Some(2)); + assert_eq!(market.escrow(order_id), Some(1)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(1) + ); + assert_ne!(market.contract_nonce(market.buyer.id()), buyer_nonce); + market.assert_supply_balanced(); +} + #[tokio::test] async fn should_return_the_tokens_of_a_cancelled_listing() { let mut market = tradeable_market(); @@ -1157,6 +1233,9 @@ async fn should_refuse_listing_more_than_the_seller_holds() { assert_eq!(market.listing(order_id), None); } +/// What a listing is worth at the decimals it signs is checked with the transition's structure: +/// two whole tokens at `i64::MAX` credits each are refused by check_tx and in a block before +/// anything is charged, and nothing moves. #[tokio::test] async fn should_refuse_a_listing_worth_more_than_an_order_holds() { let mut market = market_with( @@ -1166,23 +1245,24 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { }, dash_to_credits!(1.0), ); - // Two whole tokens at i64::MAX credits each. - let (place, _) = market.place_ask(2, i64::MAX as u64, 1, None).await; + let seller_credits = market.credit_balance(market.seller.id()); + let (place, order_id) = market.place_ask(2, i64::MAX as u64, 1, None).await; assert_matches!( market.check_tx(&place).as_slice(), - [ConsensusError::StateError( - StateError::TokenOrderValueTooLargeError(_) - )] + [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] + if error.amount() == 2 && error.decimals() == 0 ); let processing_result = market.process(&place); assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::PaidConsensusError { - error: error @ ConsensusError::StateError(StateError::TokenOrderValueTooLargeError(_)), - .. - }] if error.code() == 40757 + [StateTransitionExecutionResult::UnpaidConsensusError( + error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(_)) + )] if error.code() == 10465 ); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); } #[tokio::test] diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index b7cf6007289..4cf0844be9e 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -165,13 +165,15 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds /// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, /// as software that cannot decode them refuses them. The checks that read nothing beyond the -/// contract and the block (a tradeable token, a listing's value and expiry) run with the batch +/// contract and the block (a tradeable token, its decimals, a listing's expiry) run with the batch /// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. /// A listing's terms, its expiry included (at most `i64::MAX`, `InvalidTokenOrderExpiryError` -/// 10464), are checked with the transition's structure, unpaid. A listing signs the decimals +/// 10464) and what it is worth at the decimals it signs (at most `i64::MAX` credits, +/// `InvalidTokenOrderValueError` 10465), are checked with the transition's structure, unpaid. A listing signs the decimals /// its price is at and is refused unless they are the token's (`TokenOrderDecimalsMismatchError` -/// 40758, with the batch `advanced_structure`); it records them and its fills are priced at them; a listing of a token its contract does not -/// define bumps the nonce with the token base rules' error. Their structure and state +/// 40758, with the batch `advanced_structure`); it records them and its fills are priced at +/// them; a listing of a token its contract does not define bumps the nonce with the token base +/// rules' error. Their structure and state /// validations and their high-level operation converters are 0 in every table, which only this /// version reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 28c2f5d8ecd..d772d1997c6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -597,9 +597,14 @@ fn summarize_token_transition( } (None, None, None, t.public_note()) } - // A listing moves `amount` tokens into escrow; its price and terms are details. + // A listing moves `amount` tokens into escrow; its price, at the decimals it signs, and + // its terms are details. TokenTransition::PlaceAsk(t) => { - lines.push(budget.write(format_args!("price: {} credits per whole token", t.price()))?); + lines.push(budget.write(format_args!( + "price: {} credits per whole token at {} decimals", + t.price(), + t.decimals() + ))?); lines.push(budget.write(format_args!("minimum fill: {}", t.min_fill()))?); if let Some(expires_at) = t.expires_at() { lines.push(budget.write(format_args!("expires at: {expires_at} ms"))?); @@ -692,6 +697,8 @@ mod tests { use dpp::state_transition::batch_transition::token_emergency_action_transition::v0::TokenEmergencyActionTransitionV0; use dpp::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::v0::TokenSetPriceForDirectPurchaseTransitionV0; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; + use dpp::state_transition::batch_transition::TokenPlaceAskTransition; use dpp::state_transition::batch_transition::{ BatchTransitionV1, DocumentCreateTransition, TokenClaimTransition, TokenConfigUpdateTransition, TokenDirectPurchaseTransition, @@ -1014,6 +1021,42 @@ mod tests { /// Two config updates granting manual minting to different takers must not /// summarize the same; neither is complete from the typed fields alone. + /// A listing's row names the decimals it signs: two listings differing only in them render + /// different approvals (1,000 base units at 1,000,000,000 credits per whole token are worth + /// 10,000 credits at 8 decimals and 1 credit at 16). + #[test] + fn listing_rows_show_the_signed_decimals() { + let listing = |decimals: u8| { + BatchedTransition::Token(TokenTransition::PlaceAsk(TokenPlaceAskTransition::V0( + TokenPlaceAskTransitionV0 { + base: token_base(), + amount: 1_000, + price: 1_000_000_000, + decimals, + min_fill: 1, + expires_at: None, + }, + ))) + }; + let summary = summarize_bytes(&batch(vec![listing(8), listing(16)])); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + assert_ne!(transitions[0], transitions[1]); + let details = |row: &BatchedTransitionSummary| row.details.clone().expect("details"); + assert!( + details(&transitions[0]).contains("at 8 decimals"), + "{}", + details(&transitions[0]) + ); + assert!( + details(&transitions[1]).contains("at 16 decimals"), + "{}", + details(&transitions[1]) + ); + assert_eq!(transitions[0].amount, Some(1_000)); + } + #[test] fn token_details_cover_config_update_emergency_price_and_claim() { let config_update = |takers: AuthorizedActionTakers| { diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 2a4866d8c67..512b6e837e0 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -8,11 +8,12 @@ use crate::errors::consensus::basic::{ }; use dpp::consensus::basic::token::{ InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, + InvalidTokenOrderValueError, }; use dpp::consensus::state::token::{ TokenNotTradeableError, TokenOrderDecimalsMismatchError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderNotFoundError, TokenOrderNotOwnedError, - TokenOrderPriceLimitError, TokenOrderSelfFillError, TokenOrderValueTooLargeError, + TokenOrderPriceLimitError, TokenOrderSelfFillError, }; use dpp::consensus::ConsensusError as DPPConsensusError; @@ -556,9 +557,6 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenOrderSelfFillError(e) => { generic_consensus_error!(TokenOrderSelfFillError, e).into() } - StateError::TokenOrderValueTooLargeError(e) => { - generic_consensus_error!(TokenOrderValueTooLargeError, e).into() - } StateError::TokenOrderDecimalsMismatchError(e) => { generic_consensus_error!(TokenOrderDecimalsMismatchError, e).into() } @@ -1468,6 +1466,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidTokenOrderExpiryError(e) => { generic_consensus_error!(InvalidTokenOrderExpiryError, e).into() } + BasicError::InvalidTokenOrderValueError(e) => { + generic_consensus_error!(InvalidTokenOrderValueError, e).into() + } } } From f94a22e32c5f86f21e699df27e7186d45c97745a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 04:43:14 +0300 Subject: [PATCH 055/121] test(platform): pin listing width bounds and the revision byte a fill can add A listing's amount and price are refused at 0 and above i64::MAX, also on the processor path, unpaid and with nothing moved. The book no longer promises that partial fills never add storage: the fill that takes an order to revision 128 widens its varint $revision by a byte, paid by that fill's buyer and refunded to the seller. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 11 ++- .../validate_structure/mod.rs | 64 +++++++++++++++ .../batch/tests/token/marketplace/mod.rs | 82 +++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 32b5e07f7ca..dba6de2f0af 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -65,8 +65,9 @@ offer), each with three indexes: 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. +an order is a signed 8-byte value, so a partial fill rewrites them without changing their size. The +document's `$revision` is a variable-length integer: the one fill that takes an order from revision +127 to 128 adds a byte, and no other fill adds storage. An order's price is in credits per whole token, so the token's decimals set what it is worth. Its signer signs the decimals the price is at (`decimals`), and the order is refused @@ -161,8 +162,10 @@ checked with the batch's fees, which count a fill's cost like a direct purchase. The seller pays the storage of the `ask` and gets it back when the listing closes, by a fill that takes everything left or by a cancel. A partial fill rewrites the `ask` without changing its size, -so the buyer pays no storage for it beyond their own token balance. Selling a listing charges the -seller nothing. +so the buyer pays no storage for it beyond their own token balance, with one exception: the fill +that takes the listing to revision 128 widens its `$revision` by a byte, and that fill's buyer pays +for it. The `ask` stays the seller's, so that byte is refunded to the seller when the listing +closes. Selling a listing charges the seller nothing. The proof a node returns for each transition: diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs index c7f5726545c..52171ca913c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -34,3 +34,67 @@ impl TokenPlaceAskTransitionStructureValidation for TokenPlaceAskTransition { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; + + const SIGNED_MAX: u64 = i64::MAX as u64; + + fn errors(amount: u64, price: u64) -> Vec { + TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { + base: Default::default(), + amount, + price, + decimals: 8, + min_fill: 1, + expires_at: None, + }) + .validate_structure(PlatformVersion::latest()) + .expect("expected the structure validation to run") + .errors + } + + /// A listing's amount and price are stored as signed 8-byte values, so each is 1 to + /// `i64::MAX`. At 8 decimals, `i64::MAX + 1` base units at 1 credit and 1 base unit at + /// `i64::MAX + 1` credits are both worth what an order holds, so only these bounds refuse + /// them before they reach the order's document. + #[test] + fn should_refuse_an_amount_or_price_outside_a_signed_8_byte_value() { + for (term, accepted) in [ + (0, false), + (1, true), + (SIGNED_MAX, true), + (SIGNED_MAX + 1, false), + ] { + let by_amount = errors(term, 1); + let by_price = errors(1, term); + if accepted { + assert!(by_amount.is_empty(), "amount {term}: {by_amount:?}"); + assert!(by_price.is_empty(), "price {term}: {by_price:?}"); + } else { + assert!( + matches!( + by_amount.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(_) + )] + ), + "amount {term}: {by_amount:?}" + ); + assert!( + matches!( + by_price.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenOrderPriceError(_) + )] + ), + "price {term}: {by_price:?}" + ); + } + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 4d7d933ff6d..eba5a241637 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -1034,6 +1034,88 @@ async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { market.assert_supply_balanced(); } +/// A listing of more base units than a signed 8-byte value holds is refused by the transition's +/// structure, by check_tx and in a block, before anything is charged, and nothing moves. +#[tokio::test] +async fn should_refuse_a_listing_amount_above_i64_max() { + let mut market = tradeable_market(); + let seller_credits = market.credit_balance(market.seller.id()); + let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1, 1, None).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(_) + )] + ); + assert_matches!( + market.process(&place).execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_)) + )] + ); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +/// Every integer of an order is a signed 8-byte value, but its `$revision` is stored as a +/// varint: the partial fill that takes a listing from revision 127 to 128 grows the `ask` by a +/// byte, which that fill's buyer pays for. The fills before it, and the ones after, add no +/// storage. The listing stays its seller's: when it closes, its storage is refunded to the +/// seller alone. +#[tokio::test] +async fn should_charge_the_fill_that_widens_a_listings_revision() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(200, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + + let mut fills_adding_storage = vec![]; + for fill_number in 1..=130u64 { + let fill = market.fill_ask(order_id, 1, 1, 10).await; + let processing_result = market.process(&fill); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!( + "expected fill {fill_number} to execute: {:?}", + processing_result.execution_results() + ); + }; + // The first fill also creates the buyer's token balance. + if fill_number > 1 && fee_result.storage_fee > 0 { + fills_adding_storage.push(fill_number); + } + } + // The listing was created at revision 1, so fill 127 takes it to 128. + assert_eq!(fills_adding_storage, vec![127]); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(70) + ); + + let cancel = market.cancel(order_id, false).await; + let processing_result = market.process(&cancel); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!("expected the cancel to execute"); + }; + assert!( + fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.seller.id()) + .is_some_and(|refund| refund > 0), + "the seller gets the listing's storage back" + ); + assert_eq!( + fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.buyer.id()), + None, + "the buyer who paid for the wider revision is not refunded: the listing is the seller's" + ); +} + #[tokio::test] async fn should_return_the_tokens_of_a_cancelled_listing() { let mut market = tradeable_market(); From 88599ae48fd4be7d1ac9637fa1cd7e2974e1f94e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 04:55:18 +0300 Subject: [PATCH 056/121] test(drive-abci): the revision-byte test uses a token that keeps no transfer history So a fill records nothing but the ask, also once fills are recorded in the token history. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index eba5a241637..b1227841a80 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -11,10 +11,12 @@ use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::accessors::v1::DataContractV1Setters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::{ TokenConfigurationConventionV0Getters, TokenConfigurationConventionV0Setters, }; use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Setters; 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::DataContract; @@ -1062,10 +1064,19 @@ async fn should_refuse_a_listing_amount_above_i64_max() { /// varint: the partial fill that takes a listing from revision 127 to 128 grows the `ask` by a /// byte, which that fill's buyer pays for. The fills before it, and the ones after, add no /// storage. The listing stays its seller's: when it closes, its storage is refunded to the -/// seller alone. +/// seller alone. The token keeps no transfer history, so a fill records nothing else and only +/// the `ask` can add storage. #[tokio::test] async fn should_charge_the_fill_that_widens_a_listings_revision() { - let mut market = tradeable_market(); + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration + .keeps_history_mut() + .set_keeps_transfer_history(false); + }, + dash_to_credits!(1.0), + ); let (place, order_id) = market.place_ask(200, PRICE, 1, None).await; market.assert_executes(&place, &BlockInfo::default()); From 1cc8a2384d428640f4558fc396971df08dc4d30c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 09:47:06 +0300 Subject: [PATCH 057/121] fix(platform): listing review round 5 - The place, fill and cancel transformers build their paid failures with the shared bump_with_errors instead of a duplicate closure body. - The marketplace proof helpers assert the guarantee each transition earns: a placement's proof binds its execution, also after partial fills; a fill's and a cancel's only authenticate the state. - Two fills of one listing processed in one block: the second reads what the first left and takes the rest. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 115 ++++++++++++++++-- .../v0/transformer.rs | 19 +-- .../v0/transformer.rs | 19 +-- .../v0/transformer.rs | 19 +-- 4 files changed, 125 insertions(+), 47 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index b1227841a80..f9dc3056493 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -24,11 +24,16 @@ use dpp::document::Document; use dpp::fee::Credits; use dpp::group::GroupStateTransitionInfo; use dpp::prelude::{Identity, IdentityPublicKey}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; -use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::proof_result::{ + StateTransitionProofGuarantee, StateTransitionProofOutcome, StateTransitionProofResult, +}; use dpp::state_transition::StateTransition; use dpp::system_data_contracts::SystemDataContract; use dpp::tokens::calculate_token_id; @@ -138,6 +143,43 @@ fn market_with( } } +/// The guarantee the proof of a marketplace transition gives. A placement's binds its +/// execution: the order's document carries an id only this transition derives, and its terms, +/// also once fills have changed what is left. A fill leaves a balance snapshot and a cancel an +/// absence, which the order being filled entirely would leave too, so their proofs only +/// authenticate the state. +fn marketplace_proof_guarantee( + transition: &StateTransition, +) -> Option { + let StateTransition::Batch(batch) = transition else { + return None; + }; + match batch.first_transition()? { + BatchedTransitionRef::Token(TokenTransition::PlaceAsk(_)) => { + Some(StateTransitionProofGuarantee::ExecutionProved) + } + BatchedTransitionRef::Token( + TokenTransition::FillAsk(_) | TokenTransition::CancelOrder(_), + ) => Some(StateTransitionProofGuarantee::AffectedState), + _ => None, + } +} + +/// The verified result, once the proof's guarantee is the one `claimed` earns. +fn checked_result( + claimed: &StateTransition, + outcome: StateTransitionProofOutcome, +) -> StateTransitionProofResult { + if let Some(expected) = marketplace_proof_guarantee(claimed) { + assert_eq!( + outcome.guarantee(), + expected, + "the proof's guarantee for this marketplace transition" + ); + } + outcome.into_result() +} + fn tradeable_market() -> Market { market_with(make_token_tradeable, dash_to_credits!(1.0)) } @@ -154,18 +196,32 @@ impl Market { &mut self, transition: &StateTransition, block_info: &BlockInfo, + ) -> StateTransitionsProcessingResult { + self.process_in_one_block(&[transition], block_info) + } + + /// Processes `transitions` in order in one block transaction, committed after the last. + fn process_in_one_block( + &mut self, + transitions: &[&StateTransition], + block_info: &BlockInfo, ) -> StateTransitionsProcessingResult { let platform_version = PlatformVersion::latest(); - let serialized = transition - .serialize_to_bytes() - .expect("expected the batch to serialize"); + let serialized: Vec> = transitions + .iter() + .map(|transition| { + transition + .serialize_to_bytes() + .expect("expected the batch to serialize") + }) + .collect(); let platform_state = self.platform.state.load(); let transaction = self.platform.drive.grove.start_transaction(); let processing_result = self .platform .platform .process_raw_state_transitions( - &[serialized], + &serialized, &platform_state, block_info, &transaction, @@ -664,7 +720,7 @@ impl Market { self.committed_root(), "the proof authenticates the committed state" ); - outcome.into_result() + checked_result(claimed, outcome) }) } @@ -695,7 +751,7 @@ impl Market { self.committed_root(), "the proof authenticates the committed state" ); - outcome.into_result() + checked_result(transition, outcome) }) } @@ -725,7 +781,7 @@ impl Market { self.committed_root(), "the proof authenticates the committed state" ); - outcome.into_result() + checked_result(transition, outcome) } /// The root of the state committed so far, which a client authenticates a proof against. @@ -960,6 +1016,49 @@ async fn should_sell_part_of_a_listing_and_then_the_rest() { market.assert_supply_balanced(); } +/// Two fills of one listing in one block, each asking for up to 700 of its 1,000: the second +/// reads what the first left in the block's transaction, not the committed listing, so the +/// first takes 700 and the second the 300 left, which closes the listing. +#[tokio::test] +async fn should_fill_a_listing_twice_in_one_block() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + + let first = market.fill_ask(order_id, 1, 700, 7_000).await; + let second = market.fill_ask(order_id, 1, 700, 7_000).await; + let processing_result = market.process_in_one_block(&[&first, &second], &BlockInfo::default()); + let [StateTransitionExecutionResult::SuccessfulExecution { .. }, StateTransitionExecutionResult::SuccessfulExecution { + fee_result: closing_fee, + .. + }] = processing_result.execution_results().as_slice() + else { + panic!( + "expected both fills to execute: {:?}", + processing_result.execution_results() + ); + }; + let listing_refund = closing_fee + .fee_refunds + .calculate_refunds_amount_for_identity(market.seller.id()) + .expect("expected the closing fill to refund the seller's listing"); + + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.listing(order_id), None, "the second fill closed it"); + assert_eq!(market.escrow(order_id), None); + // 700 and 300 base units at 10 credits each, and the listing's storage back. + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 10_000 + listing_refund + ); + market.assert_supply_balanced(); +} + /// 1.5 credits per base unit: two fills of 3 cost 5 and 4, the 9 the 6 units are worth, as /// each fill pays the change in the listing's rounded-up running total. The listing keeps one /// unit open, so no storage refund mixes into the seller's balance. diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs index 85094064aa7..0f2cec6e4a2 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs @@ -17,9 +17,8 @@ use crate::drive::Drive; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::v0::TokenCancelOrderTransitionActionV0; -use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; use crate::state_transition_action::batch::BatchedTransitionAction; -use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; impl TokenCancelOrderTransitionActionV0 { /// Converts a borrowed `TokenCancelOrderTransitionV0` into an action: reads the order, which @@ -74,17 +73,11 @@ impl TokenCancelOrderTransitionActionV0 { )?; let bump = |errors: Vec, fee_result: FeeResult| { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( - base, - owner_id, - user_fee_increase, - ); - Ok(( - ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), - errors, - ), + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, fee_result, )) }; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index 7620eef75e3..6b109462896 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -20,9 +20,8 @@ use crate::drive::Drive; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::v0::TokenFillAskTransitionActionV0; -use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; use crate::state_transition_action::batch::BatchedTransitionAction; -use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; impl TokenFillAskTransitionActionV0 { /// Converts a borrowed `TokenFillAskTransitionV0` into an action: reads the listing, and @@ -80,17 +79,11 @@ impl TokenFillAskTransitionActionV0 { )?; let bump = |errors: Vec, fee_result: FeeResult| { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( - base, - owner_id, - user_fee_increase, - ); - Ok(( - ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), - errors, - ), + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, fee_result, )) }; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index 26a30b2f97b..b9e2b23d490 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -16,9 +16,8 @@ use crate::drive::Drive; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::v0::TokenPlaceAskTransitionActionV0; -use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; use crate::state_transition_action::batch::BatchedTransitionAction; -use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; impl TokenPlaceAskTransitionActionV0 { /// Converts a borrowed `TokenPlaceAskTransitionV0` into an action. The listing's order id @@ -78,17 +77,11 @@ impl TokenPlaceAskTransitionActionV0 { )?; let bump = |errors: Vec, fee_result: FeeResult| { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( - base, - owner_id, - user_fee_increase, - ); - Ok(( - ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), - errors, - ), + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, fee_result, )) }; From 2060a1b3e3323a02b30a98045f8645f1106cfd71 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 11:25:38 +0300 Subject: [PATCH 058/121] fix(drive): a listing pays for the hash that derives its id The place-ask transformation derives the order id with a double SHA-256 and now charges it there: derive_order_id returns the id with the fee of one Sha256_2 function operation over its preimage (TokenOrderSide::order_id_preimage_size, 75 bytes), added to the fee the transformation has accrued, so a successful placement and a paid refusal after the derivation both carry it. The book no longer treats revision 128 as the only point a fill widens a listing's varint $revision: every further 7 bits adds a byte too. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 13 ++- .../rs-dpp/src/tokens/token_order_side.rs | 6 + .../batch/tests/token/marketplace/mod.rs | 108 ++++++++++++++++++ .../token_transition/mod.rs | 34 ++++++ .../v0/transformer.rs | 16 ++- 5 files changed, 167 insertions(+), 10 deletions(-) diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index dba6de2f0af..03c882de43d 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -66,8 +66,9 @@ offer), each with three indexes: 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 them without changing their size. The -document's `$revision` is a variable-length integer: the one fill that takes an order from revision -127 to 128 adds a byte, and no other fill adds storage. +document's `$revision` is a variable-length integer, 7 bits to a byte: a fill that takes an order's +revision past one of those widths (from 127 to 128, then from 16,383 to 16,384, and so on) adds a +byte, and no other fill adds storage. An order's price is in credits per whole token, so the token's decimals set what it is worth. Its signer signs the decimals the price is at (`decimals`), and the order is refused @@ -162,10 +163,10 @@ checked with the batch's fees, which count a fill's cost like a direct purchase. The seller pays the storage of the `ask` and gets it back when the listing closes, by a fill that takes everything left or by a cancel. A partial fill rewrites the `ask` without changing its size, -so the buyer pays no storage for it beyond their own token balance, with one exception: the fill -that takes the listing to revision 128 widens its `$revision` by a byte, and that fill's buyer pays -for it. The `ask` stays the seller's, so that byte is refunded to the seller when the listing -closes. Selling a listing charges the seller nothing. +so the buyer pays no storage for it beyond their own token balance, except when a fill widens the +listing's `$revision` by a byte (first at revision 128, then at 16,384 and at every further 7 +bits): that fill's buyer pays for the byte. The `ask` stays the seller's, so those bytes are +refunded to the seller when the listing closes. Selling a listing charges the seller nothing. The proof a node returns for each transition: diff --git a/packages/rs-dpp/src/tokens/token_order_side.rs b/packages/rs-dpp/src/tokens/token_order_side.rs index c5960a5d2a2..14164d825ee 100644 --- a/packages/rs-dpp/src/tokens/token_order_side.rs +++ b/packages/rs-dpp/src/tokens/token_order_side.rs @@ -84,4 +84,10 @@ impl TokenOrderSide { nonce.to_be_bytes().as_slice(), ) } + + /// The number of bytes `order_id` hashes (twice, with SHA-256): the token id and the maker's + /// id (32 bytes each), the document type name and the nonce. + pub fn order_id_preimage_size(&self) -> usize { + 2 * 32 + self.document_type_name().len() + size_of::() + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index f9dc3056493..03a19d990b7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -30,7 +30,9 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; use dpp::state_transition::proof_result::{ StateTransitionProofGuarantee, StateTransitionProofOutcome, StateTransitionProofResult, }; @@ -41,6 +43,7 @@ use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use drive::drive::Drive; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionActionV0; use drive::error::proof::ProofError; use drive::error::Error as DriveError; use simple_signer::signer::SimpleSigner; @@ -1251,6 +1254,111 @@ async fn should_return_the_tokens_of_a_cancelled_listing() { ); } +/// What deriving a listing's id costs: a double SHA-256 over its 75-byte preimage (the token +/// id, the seller's id, "ask" and the nonce), 2 blocks hashed and the second SHA-256 a third. +fn listing_id_hash_fee() -> Credits { + let hashing = &PlatformVersion::latest().fee_version.hashing; + hashing.single_sha256_base + 3 * hashing.sha256_per_block +} + +/// The placement's transformation derives the listing's id and charges the hash: for a token +/// outside a group it reads nothing else, so the hash is all it charges. A placement naming a +/// token the contract does not define is refused before the id is derived and charges nothing. +#[tokio::test] +async fn should_charge_a_listing_the_hash_of_its_id_where_it_is_derived() { + let market = tradeable_market(); + let platform_version = PlatformVersion::latest(); + let contract = market + .platform + .drive + .get_contract_with_fetch_info( + market.contract.id().to_buffer(), + false, + None, + platform_version, + ) + .expect("expected to fetch the contract") + .expect("expected the token's contract"); + let transform = |position: u16| { + let transition = TokenPlaceAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 2, + token_contract_position: position, + data_contract_id: market.contract.id(), + token_id: Identifier::from(calculate_token_id( + market.contract.id().as_bytes(), + position, + )), + using_group_info: None, + }), + amount: 1_000, + price: PRICE, + decimals: 8, + min_fill: 1, + expires_at: None, + }; + TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( + &market.platform.drive, + market.seller.id(), + &transition, + false, + None, + &BlockInfo::default(), + 0, + |_| Ok(Arc::clone(&contract)), + platform_version, + ) + .expect("expected the transformation to run") + }; + + let (placed, fee) = transform(0); + assert!(placed.is_valid(), "{:?}", placed.errors); + assert_eq!(fee.processing_fee, listing_id_hash_fee()); + + let (refused, fee) = transform(1); + assert_matches!( + refused.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenPositionError(_) + )] + ); + assert_eq!(fee.processing_fee, 0, "the id was never derived"); +} + +/// A placement refused after its id is derived (a token that is not tradeable, refused by the +/// batch's advanced structure) pays for the hash on top of what one refused before the +/// derivation (a token the contract does not define) pays. A first refusal creates the seller's +/// nonce entry for the contract, so the two compared both update it. +#[tokio::test] +async fn should_charge_a_listing_refused_after_its_id_is_derived_the_hash() { + let mut market = market_with(|_| {}, dash_to_credits!(1.0)); + let contract_id = market.contract.id(); + let mut refuse = async |market: &mut Market, position: u16| { + let nonce = market.seller.take_nonce(); + let place = market.place_ask_naming(contract_id, position, nonce).await; + let processing_result = market.process(&place); + match processing_result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error, actual_fees, .. + }] => (error.clone(), actual_fees.processing_fee), + results => panic!("expected a paid refusal: {results:?}"), + } + }; + + refuse(&mut market, 1).await; + let (error, refused_before) = refuse(&mut market, 1).await; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)) + ); + let (error, refused_after) = refuse(&mut market, 0).await; + assert_matches!( + error, + ConsensusError::StateError(StateError::TokenNotTradeableError(_)) + ); + assert_eq!(refused_after, refused_before + listing_id_hash_fee()); +} + #[tokio::test] async fn should_refuse_listing_a_token_that_is_not_tradeable() { let mut market = market_with(|_| {}, dash_to_credits!(1.0)); diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index c0b84901ff5..8801c57fee4 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -73,9 +73,13 @@ use dpp::prelude::{ ConsensusValidationResult, DataContract, IdentityNonce, UserFeeIncrease, }; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::tokens::token_order_side::TokenOrderSide; use dpp::ProtocolError; use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; +use crate::fees::op::{FunctionOp, HashFunction, LowLevelDriveOperation}; use crate::state_transition_action::batch::BatchedTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; @@ -337,3 +341,33 @@ pub(crate) fn bump_with_errors( fee_result, ) } + +/// The id of the order of \`side\` that \`maker_id\` places for \`token_id\` with the identity +/// contract nonce \`nonce\`, and the fee of deriving it: a double SHA-256 over the id's preimage, +/// charged where the placement's transformation computes it, as a document create's id is. +pub(crate) fn derive_order_id( + drive: &Drive, + side: TokenOrderSide, + token_id: Identifier, + maker_id: Identifier, + nonce: IdentityNonce, + block_info: &BlockInfo, + platform_version: &PlatformVersion, +) -> Result<(Identifier, FeeResult), Error> { + let preimage_size = u16::try_from(side.order_id_preimage_size()).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "an order id's preimage is a few dozen bytes", + )) + })?; + let hash_fee = Drive::calculate_fee( + None, + Some(vec![LowLevelDriveOperation::FunctionOperation( + FunctionOp::new_with_byte_count(HashFunction::Sha256_2, preimage_size), + )]), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + Ok((side.order_id(token_id, maker_id, nonce), hash_fee)) +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index b9e2b23d490..c71ab0375d0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -16,7 +16,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::v0::TokenPlaceAskTransitionActionV0; -use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, derive_order_id, TokenTransitionAction}; use crate::state_transition_action::batch::BatchedTransitionAction; impl TokenPlaceAskTransitionActionV0 { @@ -67,7 +67,7 @@ impl TokenPlaceAskTransitionActionV0 { platform_version, )?; - let fee_result = Drive::calculate_fee( + let mut fee_result = Drive::calculate_fee( None, Some(drive_operations), &block_info.epoch, @@ -98,8 +98,16 @@ impl TokenPlaceAskTransitionActionV0 { // it unless they are the token's. let decimals = *decimals; - let order_id = - TokenOrderSide::Ask.order_id(base.token_id(), owner_id, base.identity_contract_nonce()); + let (order_id, order_id_fee) = derive_order_id( + drive, + TokenOrderSide::Ask, + base.token_id(), + owner_id, + base.identity_contract_nonce(), + block_info, + platform_version, + )?; + fee_result.checked_add_assign(order_id_fee)?; let order_document = TokenOrder { id: order_id, side: TokenOrderSide::Ask, From ce1baf5e0b9835a5e7eda87e2d04a51852ae582a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 13:42:50 +0300 Subject: [PATCH 059/121] 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 060/121] 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 10f4c70139b292cbdeacac887bf3f958e8c00dc1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 14:02:09 +0300 Subject: [PATCH 061/121] feat(platform)!: listings carry no expiry TokenPlaceAsk loses expiresAt, with InvalidTokenOrderExpiryError (10464) and TokenOrderExpiredError (40752): an expiry stopped fills but left the listing and its escrow in place until its seller cancelled, and the book and depth indexes could not tell an expired listing apart. A seller withdraws a listing by cancelling it. TokenOrder no longer stamps an order's document with its creation block height either. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 11 +- .../src/errors/consensus/basic/basic_error.rs | 16 +-- .../token/invalid_token_order_expiry_error.rs | 60 -------- .../src/errors/consensus/basic/token/mod.rs | 2 - packages/rs-dpp/src/errors/consensus/codes.rs | 2 - .../src/errors/consensus/state/state_error.rs | 23 +-- .../src/errors/consensus/state/token/mod.rs | 2 - .../state/token/token_order_expired_error.rs | 69 --------- .../batched_transition/token_order_terms.rs | 52 +------ .../token_place_ask_transition/v0/mod.rs | 8 +- .../v0/v0_methods.rs | 13 -- .../token_place_ask_transition/v0_methods.rs | 13 -- .../validate_structure/mod.rs | 1 - .../validate_structure/v0/mod.rs | 1 - .../document/batch_transition/methods/mod.rs | 3 - .../batch_transition/methods/v1/mod.rs | 2 - .../batch_transition/v1/v1_methods.rs | 3 - packages/rs-dpp/src/tokens/token_order.rs | 26 +--- .../state_v0/mod.rs | 4 +- .../structure_v0/mod.rs | 23 +-- .../batch/advanced_structure/v2/mod.rs | 4 +- .../batch/tests/token/marketplace/mod.rs | 134 ++++-------------- .../v0/transformer.rs | 17 +-- .../token_place_ask_transition_action/mod.rs | 7 - .../v0/mod.rs | 10 -- .../v0/transformer.rs | 3 - .../v0/mod.rs | 1 - .../rs-platform-version/src/version/v15.rs | 5 +- .../network/state_transition_summary.rs | 4 - .../src/errors/consensus/consensus_error.rs | 15 +- .../token_transitions/token_place_ask.rs | 27 +--- .../tests/unit/TokensTransitions.spec.ts | 9 +- 32 files changed, 72 insertions(+), 498 deletions(-) delete mode 100644 packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs delete mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 03c882de43d..7d2f1a2553b 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -104,7 +104,7 @@ Three token transitions of a batch work with listings. Each is refused before pr | Transition | Fields | Effect | |---|---|---| -| `TokenPlaceAsk` | `amount`, `price`, `decimals`, `minFill`, optional `expiresAt` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | +| `TokenPlaceAsk` | `amount`, `price`, `decimals`, `minFill` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | | `TokenFillAsk` | `orderId`, `minAmount`, `maxAmount`, `maxTotalPrice` | Moves part or all of the listing from escrow to the buyer, and its cost from the buyer's credits to the seller's | | `TokenCancelOrder` | `orderId`, `side` | Returns what an order holds to its owner and deletes the order | @@ -137,13 +137,12 @@ The rounding favours the seller and is never more than one credit per fill. | Rule | Refused with | |---|---| -| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, `expiresAt` at most `i64::MAX`, and what the order is worth at the `decimals` it signs at most `i64::MAX` credits | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderExpiryError` (10464), `InvalidTokenOrderValueError` (10465) | +| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, and what the order is worth at the `decimals` it signs at most `i64::MAX` credits | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderValueError` (10465) | | A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | | The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` | | The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | | A listing's `decimals` are the token's | `TokenOrderDecimalsMismatchError` (40758) | | The order is open, for this token | `TokenOrderNotFoundError` (40751) | -| A listing's `expiresAt` is after the block, and a fill comes before it | `TokenOrderExpiredError` (40752) | | The fill range meets the listing | `TokenOrderFillAmountError` (40753) | | The fill costs at most `maxTotalPrice` | `TokenOrderPriceLimitError` (40754) | | Only the order's owner cancels it | `TokenOrderNotOwnedError` (40755) | @@ -154,9 +153,9 @@ The rounding favours the seller and is never more than one credit per fill. The rules on a transition's own fields (the first two rows) are checked before anything is read and refused unpaid. The state rules are paid: a refused transition bumps the signer's nonce and charges -the work that ran. The rules that read nothing beyond the contract and the block (a tradeable token, a listing's -value and its expiry) run with the batch's advanced structure, so check_tx refuses them too. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the -token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are +the work that ran. The rules that read nothing beyond the contract (a tradeable token and the +decimals a listing signs) run with the batch's advanced structure, so check_tx refuses them too. A +cancel has no rule beyond ownership, so an order can always be withdrawn, also when the token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are checked with the batch's fees, which count a fill's cost like a direct purchase. ### Storage and proofs 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 818d9c07f3b..07cfa43d49d 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -112,8 +112,7 @@ use crate::consensus::basic::state_transition::{ WithdrawalBelowMinAmountError, }; use crate::consensus::basic::token::{ - InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, - InvalidTokenOrderValueError, + InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, InvalidTokenOrderValueError, }; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, @@ -882,10 +881,6 @@ pub enum BasicError { #[error(transparent)] InvalidTokenOrderFillRangeError(InvalidTokenOrderFillRangeError), - // A token order's structure (protocol version 15). - #[error(transparent)] - InvalidTokenOrderExpiryError(InvalidTokenOrderExpiryError), - // A token order's structure (protocol version 15). #[error(transparent)] InvalidTokenOrderValueError(InvalidTokenOrderValueError), @@ -1120,18 +1115,11 @@ mod tests { 208 ); // A token order's structure (protocol version 15). - assert_eq!( - discriminant_of(BasicError::InvalidTokenOrderExpiryError( - InvalidTokenOrderExpiryError::new(0, 1) - )), - 209 - ); - // A token order's structure (protocol version 15). assert_eq!( discriminant_of(BasicError::InvalidTokenOrderValueError( InvalidTokenOrderValueError::new(u64::MAX, u64::MAX, 0) )), - 210 + 209 ); } diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs deleted file mode 100644 index afae736c5e1..00000000000 --- a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_expiry_error.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; -use crate::prelude::TimestampMillis; -use crate::ProtocolError; -use bincode::{Decode, DecodeUntrusted, Encode}; -use platform_serialization_derive::{ - PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, -}; -use thiserror::Error; - -/// A token order's expiry is a block time in milliseconds that the order's document stores as a -/// signed 8-byte value, so it is at most `i64::MAX`. Whether it is after the block is checked -/// when the order is placed. -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error("Token order expiry {expires_at} must be at most {max_expires_at} milliseconds")] -#[platform_serialize(unversioned)] -pub struct InvalidTokenOrderExpiryError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - expires_at: TimestampMillis, - max_expires_at: TimestampMillis, -} - -impl InvalidTokenOrderExpiryError { - pub fn new(expires_at: TimestampMillis, max_expires_at: TimestampMillis) -> Self { - Self { - expires_at, - max_expires_at, - } - } - - pub fn expires_at(&self) -> TimestampMillis { - self.expires_at - } - - pub fn max_expires_at(&self) -> TimestampMillis { - self.max_expires_at - } -} - -impl From for ConsensusError { - fn from(err: InvalidTokenOrderExpiryError) -> Self { - Self::BasicError(BasicError::InvalidTokenOrderExpiryError(err)) - } -} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index d8f77191335..22a4a996b99 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -39,7 +39,5 @@ mod invalid_token_order_price_error; pub use invalid_token_order_price_error::*; mod invalid_token_order_fill_range_error; pub use invalid_token_order_fill_range_error::*; -mod invalid_token_order_expiry_error; -pub use invalid_token_order_expiry_error::*; mod invalid_token_order_value_error; pub use invalid_token_order_value_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 03e736a3045..9e5de7b8ae1 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -128,7 +128,6 @@ impl ErrorWithCode for BasicError { Self::NonTransferableTokenTradeableError(_) => 10281, Self::InvalidTokenOrderPriceError(_) => 10462, Self::InvalidTokenOrderFillRangeError(_) => 10463, - Self::InvalidTokenOrderExpiryError(_) => 10464, Self::InvalidTokenOrderValueError(_) => 10465, // Group Errors: 10350-10399 @@ -394,7 +393,6 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentPrecededError(_) => 40149, Self::TokenNotTradeableError(_) => 40750, Self::TokenOrderNotFoundError(_) => 40751, - Self::TokenOrderExpiredError(_) => 40752, Self::TokenOrderFillAmountError(_) => 40753, Self::TokenOrderPriceLimitError(_) => 40754, Self::TokenOrderNotOwnedError(_) => 40755, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 706da804e05..4193e2991a8 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -5,7 +5,7 @@ use platform_serialization_derive::{ }; use thiserror::Error; -use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderDecimalsMismatchError}; +use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderDecimalsMismatchError}; use crate::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use crate::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use crate::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -755,10 +755,6 @@ pub enum StateError { #[error(transparent)] TokenOrderNotFoundError(TokenOrderNotFoundError), - // The token marketplace (protocol version 15). - #[error(transparent)] - TokenOrderExpiredError(TokenOrderExpiredError), - // The token marketplace (protocol version 15). #[error(transparent)] TokenOrderFillAmountError(TokenOrderFillAmountError), @@ -1723,25 +1719,18 @@ mod tests { 177 ); // The token marketplace (protocol version 15). - assert_eq!( - discriminant_of(StateError::TokenOrderExpiredError( - TokenOrderExpiredError::new(Identifier::from([1; 32]), 1, 2) - )), - 178 - ); - // The token marketplace (protocol version 15). assert_eq!( discriminant_of(StateError::TokenOrderFillAmountError( TokenOrderFillAmountError::new(Identifier::from([1; 32]), 1, 2, 3, 4) )), - 179 + 178 ); // The token marketplace (protocol version 15). assert_eq!( discriminant_of(StateError::TokenOrderPriceLimitError( TokenOrderPriceLimitError::new(Identifier::from([1; 32]), 2, 1) )), - 180 + 179 ); // The token marketplace (protocol version 15). assert_eq!( @@ -1752,21 +1741,21 @@ mod tests { Identifier::from([1; 32]) ) )), - 181 + 180 ); // The token marketplace (protocol version 15). assert_eq!( discriminant_of(StateError::TokenOrderSelfFillError( TokenOrderSelfFillError::new(Identifier::from([1; 32]), Identifier::from([1; 32])) )), - 182 + 181 ); // The token marketplace (protocol version 15). assert_eq!( discriminant_of(StateError::TokenOrderDecimalsMismatchError( TokenOrderDecimalsMismatchError::new(Identifier::from([1; 32]), 8, 2) )), - 183 + 182 ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index 9feb894131c..19de76a741b 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -71,8 +71,6 @@ mod token_not_tradeable_error; pub use token_not_tradeable_error::*; mod token_order_not_found_error; pub use token_order_not_found_error::*; -mod token_order_expired_error; -pub use token_order_expired_error::*; mod token_order_fill_amount_error; pub use token_order_fill_amount_error::*; mod token_order_price_limit_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs deleted file mode 100644 index 5bee9b10e00..00000000000 --- a/packages/rs-dpp/src/errors/consensus/state/token/token_order_expired_error.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::consensus::state::state_error::StateError; -use crate::consensus::ConsensusError; -use crate::prelude::TimestampMillis; -use crate::ProtocolError; -use bincode::{Decode, DecodeUntrusted, Encode}; -use platform_serialization_derive::{ - PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, -}; -use platform_value::Identifier; -use thiserror::Error; - -/// A token order's expiry has passed, or a new order expires before it could be filled. -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error("Token order {order_id} expires at {expires_at}, at or before the block time {block_time}")] -#[platform_serialize(unversioned)] -pub struct TokenOrderExpiredError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - order_id: Identifier, - expires_at: TimestampMillis, - block_time: TimestampMillis, -} - -impl TokenOrderExpiredError { - pub fn new( - order_id: Identifier, - expires_at: TimestampMillis, - block_time: TimestampMillis, - ) -> Self { - Self { - order_id, - expires_at, - block_time, - } - } - - pub fn order_id(&self) -> Identifier { - self.order_id - } - - pub fn expires_at(&self) -> TimestampMillis { - self.expires_at - } - - pub fn block_time(&self) -> TimestampMillis { - self.block_time - } -} - -impl From for ConsensusError { - fn from(err: TokenOrderExpiredError) -> Self { - Self::StateError(StateError::TokenOrderExpiredError(err)) - } -} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs index d792302734d..b914a60e0aa 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs @@ -3,31 +3,28 @@ use crate::balances::credits::TokenAmount; use crate::consensus::basic::token::{ - InvalidTokenAmountError, InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, - InvalidTokenOrderPriceError, InvalidTokenOrderValueError, + InvalidTokenAmountError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, + InvalidTokenOrderValueError, }; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::fee::Credits; -use crate::prelude::TimestampMillis; use crate::tokens::token_order::TokenOrder; use crate::validation::SimpleConsensusValidationResult; -/// The largest amount, price and expiry an order may carry: its document stores each as a signed +/// The largest amount and price an order may carry: its document stores each as a signed /// 8-byte value, and its value is computed from the amount and the price in u128. pub(crate) const MAX_TOKEN_ORDER_TERM: u64 = i64::MAX as u64; /// The amount and the price are between 1 and `i64::MAX`, the minimum fill between 1 and the -/// amount, an expiry at most `i64::MAX`, and what the order is worth at the decimals it signs -/// (`TokenOrder::order_value`) at most `i64::MAX` credits. Whether the signed decimals are the -/// token's and whether it expires after the block are checked with the batch's advanced -/// structure, which knows the token and the block. +/// amount, and what the order is worth at the decimals it signs (`TokenOrder::order_value`) at +/// most `i64::MAX` credits. Whether the signed decimals are the token's is checked with the +/// batch's advanced structure, which knows the token. pub(crate) fn validate_token_order_terms( amount: TokenAmount, price: Credits, decimals: u8, min_fill: TokenAmount, - expires_at: Option, ) -> SimpleConsensusValidationResult { if amount == 0 || amount > MAX_TOKEN_ORDER_TERM { return SimpleConsensusValidationResult::new_with_error(ConsensusError::BasicError( @@ -47,13 +44,6 @@ pub(crate) fn validate_token_order_terms( InvalidTokenOrderFillRangeError::new(min_fill, amount).into(), ); } - if let Some(expires_at) = expires_at { - if expires_at > MAX_TOKEN_ORDER_TERM { - return SimpleConsensusValidationResult::new_with_error( - InvalidTokenOrderExpiryError::new(expires_at, MAX_TOKEN_ORDER_TERM).into(), - ); - } - } if TokenOrder::order_value(amount, price, decimals).is_none() { return SimpleConsensusValidationResult::new_with_error( InvalidTokenOrderValueError::new(amount, price, decimals).into(), @@ -66,22 +56,13 @@ pub(crate) fn validate_token_order_terms( mod tests { use super::*; - fn errors( - amount: TokenAmount, - price: Credits, - min_fill: TokenAmount, - expires_at: Option, - ) -> Vec { - validate_token_order_terms(amount, price, 8, min_fill, expires_at).errors - } - /// An order is worth its amount at its price at the decimals it signs, at most `i64::MAX` /// credits: 1 base unit at `i64::MAX` credits per whole token is worth exactly that at 0 /// decimals, 2 are worth more; at 8 decimals 2 are worth far less. #[test] fn should_refuse_an_order_worth_more_than_an_order_holds() { let at = |amount, decimals| { - validate_token_order_terms(amount, MAX_TOKEN_ORDER_TERM, decimals, 1, None).errors + validate_token_order_terms(amount, MAX_TOKEN_ORDER_TERM, decimals, 1).errors }; assert!(at(1, 0).is_empty()); assert!(at(2, 8).is_empty()); @@ -93,23 +74,4 @@ mod tests { && error.decimals() == 0 )); } - - #[test] - fn should_accept_an_expiry_up_to_i64_max() { - assert!(errors(1_000, 1, 1, None).is_empty()); - assert!(errors(1_000, 1, 1, Some(0)).is_empty()); - assert!(errors(1_000, 1, 1, Some(MAX_TOKEN_ORDER_TERM)).is_empty()); - } - - #[test] - fn should_refuse_an_expiry_above_i64_max() { - for expires_at in [MAX_TOKEN_ORDER_TERM + 1, u64::MAX] { - assert!(matches!( - errors(1_000, 1, 1, Some(expires_at)).as_slice(), - [ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(error))] - if error.expires_at() == expires_at - && error.max_expires_at() == MAX_TOKEN_ORDER_TERM - )); - } - } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs index c97947f36f2..b219b7919c4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs @@ -2,7 +2,6 @@ pub mod v0_methods; use crate::balances::credits::TokenAmount; use crate::fee::Credits; -use crate::prelude::TimestampMillis; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use bincode::{Decode, DecodeUntrusted, Encode}; #[cfg(feature = "serde-conversion")] @@ -39,17 +38,14 @@ pub struct TokenPlaceAskTransitionV0 { /// open; a fill may not leave less than this open either. Equal to `amount` for a listing /// that is sold all at once or not at all. pub min_fill: TokenAmount, - /// The block time from which the listing can no longer be filled. It can still be - /// cancelled. - pub expires_at: Option, } impl fmt::Display for TokenPlaceAskTransitionV0 { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "Token PlaceAsk, base: {}, amount: {}, price: {} at {} decimals, min fill: {}, expires at: {:?}", - self.base, self.amount, self.price, self.decimals, self.min_fill, self.expires_at + "Token PlaceAsk, base: {}, amount: {}, price: {} at {} decimals, min fill: {}", + self.base, self.amount, self.price, self.decimals, self.min_fill ) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs index 4f20dc5fde2..3d32a9a5894 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs @@ -1,6 +1,5 @@ use crate::balances::credits::TokenAmount; use crate::fee::Credits; -use crate::prelude::TimestampMillis; use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; @@ -36,10 +35,6 @@ pub trait TokenPlaceAskTransitionV0Methods: TokenBaseTransitionAccessors { fn min_fill(&self) -> TokenAmount; fn set_min_fill(&mut self, min_fill: TokenAmount); - - fn expires_at(&self) -> Option; - - fn set_expires_at(&mut self, expires_at: Option); } impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { @@ -74,12 +69,4 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { fn set_min_fill(&mut self, min_fill: TokenAmount) { self.min_fill = min_fill; } - - fn expires_at(&self) -> Option { - self.expires_at - } - - fn set_expires_at(&mut self, expires_at: Option) { - self.expires_at = expires_at; - } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs index a1199212f44..59c1ef0b1bb 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs @@ -1,6 +1,5 @@ use crate::balances::credits::TokenAmount; use crate::fee::Credits; -use crate::prelude::TimestampMillis; use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; @@ -74,16 +73,4 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransition { TokenPlaceAskTransition::V0(v0) => v0.set_min_fill(min_fill), } } - - fn expires_at(&self) -> Option { - match self { - TokenPlaceAskTransition::V0(v0) => v0.expires_at(), - } - } - - fn set_expires_at(&mut self, expires_at: Option) { - match self { - TokenPlaceAskTransition::V0(v0) => v0.set_expires_at(expires_at), - } - } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs index 52171ca913c..461016fe39c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -51,7 +51,6 @@ mod tests { price, decimals: 8, min_fill: 1, - expires_at: None, }) .validate_structure(PlatformVersion::latest()) .expect("expected the structure validation to run") diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs index f9d1ea9e487..48e9a9d978a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -16,7 +16,6 @@ impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { self.price(), self.decimals(), self.min_fill(), - self.expires_at(), )) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 72d9eb6084e..733873ad92d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -20,7 +20,6 @@ use crate::identity::signer::Signer; use crate::identity::IdentityPublicKey; use crate::prelude::IdentityNonce; #[cfg(feature = "state-transition-signing")] -use crate::prelude::TimestampMillis; #[cfg(feature = "state-transition-signing")] use crate::prelude::UserFeeIncrease; #[cfg(feature = "state-transition-signing")] @@ -1586,7 +1585,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { price: Credits, decimals: u8, min_fill: TokenAmount, - expires_at: Option, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, @@ -1619,7 +1617,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { price, decimals, min_fill, - expires_at, identity_public_key, identity_contract_nonce, user_fee_increase, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs index 6eb0df93fd4..5acacbd4be9 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs @@ -13,7 +13,6 @@ use crate::identity::signer::Signer; #[cfg(feature = "state-transition-signing")] use crate::identity::IdentityPublicKey; #[cfg(feature = "state-transition-signing")] -use crate::prelude::TimestampMillis; #[cfg(feature = "state-transition-signing")] use crate::prelude::{IdentityNonce, UserFeeIncrease}; #[cfg(feature = "state-transition-signing")] @@ -601,7 +600,6 @@ pub trait DocumentsBatchTransitionMethodsV1: DocumentsBatchTransitionAccessorsV0 price: Credits, decimals: u8, min_fill: TokenAmount, - expires_at: Option, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 7c44d8bb685..bf30104b494 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -91,7 +91,6 @@ use crate::state_transition::GetDataContractSecurityLevelRequirementFn; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] -use crate::prelude::TimestampMillis; #[cfg(feature = "state-transition-signing")] use crate::tokens::token_order_side::TokenOrderSide; #[cfg(feature = "state-transition-signing")] @@ -1506,7 +1505,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { price: Credits, decimals: u8, min_fill: TokenAmount, - expires_at: Option, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, @@ -1526,7 +1524,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { price, decimals, min_fill, - expires_at, }); let batch_transition: BatchTransition = BatchTransitionV1 { diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs index 3f6e0122102..33db4b69350 100644 --- a/packages/rs-dpp/src/tokens/token_order.rs +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -2,7 +2,7 @@ use crate::balances::credits::TokenAmount; use crate::block::block_info::BlockInfo; use crate::document::{Document, DocumentV0, DocumentV0Getters}; use crate::fee::Credits; -use crate::prelude::{Revision, TimestampMillis}; +use crate::prelude::Revision; use crate::tokens::token_order_side::TokenOrderSide; use crate::ProtocolError; use platform_value::{Identifier, Value}; @@ -15,7 +15,6 @@ pub mod property_names { 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"; } @@ -50,8 +49,6 @@ pub struct TokenOrder { /// The smallest amount a fill takes unless it takes everything open; a fill never leaves /// less than this open either. pub min_fill: TokenAmount, - /// The block time from which the order can no longer be filled. - pub expires_at: Option, /// For an offer, the credits still held in escrow; 0 for a listing, whose escrow holds /// tokens (`remaining`). pub escrow: Credits, @@ -81,11 +78,6 @@ impl TokenOrder { self.amount.saturating_sub(self.remaining) } - /// Whether the order can no longer be filled at the block time `time`. - pub fn is_expired_at(&self, time: TimestampMillis) -> bool { - self.expires_at.is_some_and(|expires_at| time >= expires_at) - } - /// The amount a fill asking for between `min_amount` and `max_amount` takes, or `None` when /// no amount in that range keeps the order's rules. /// @@ -168,14 +160,6 @@ impl TokenOrder { })? .to_identifier() .map_err(ProtocolError::ValueError)?; - let expires_at = match properties.get(property_names::EXPIRES_AT) { - None | Some(Value::Null) => None, - Some(value) => Some( - value - .to_integer::() - .map_err(ProtocolError::ValueError)?, - ), - }; let escrow = match side { TokenOrderSide::Ask => 0, TokenOrderSide::Bid => integer(property_names::ESCROW)?, @@ -194,7 +178,6 @@ impl TokenOrder { amount: integer(property_names::AMOUNT)?, remaining: integer(property_names::REMAINING)?, min_fill: integer(property_names::MIN_FILL)?, - expires_at, escrow, decimals, }) @@ -225,9 +208,6 @@ impl TokenOrder { Value::I64(self.decimals as i64), ), ]); - if let Some(expires_at) = self.expires_at { - properties.insert(property_names::EXPIRES_AT.to_string(), signed(expires_at)?); - } if self.side == TokenOrderSide::Bid { properties.insert(property_names::ESCROW.to_string(), signed(self.escrow)?); } @@ -245,7 +225,7 @@ impl TokenOrder { created_at: Some(block_info.time_ms), updated_at: Some(block_info.time_ms), transferred_at: None, - created_at_block_height: Some(block_info.height), + created_at_block_height: None, updated_at_block_height: None, transferred_at_block_height: None, created_at_core_block_height: None, @@ -294,7 +274,6 @@ mod tests { amount, remaining, min_fill, - expires_at: None, escrow: 0, decimals: 2, } @@ -383,7 +362,6 @@ mod tests { fn should_round_trip_through_its_document() { let mut offer = order(TokenOrderSide::Bid, 1000, 400, 10); offer.escrow = 1998; - offer.expires_at = Some(5_000); let block_info = BlockInfo::default(); let document = offer .new_document(&block_info) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs index c6280055c13..c56e4257c89 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs @@ -30,8 +30,8 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl TokenPlaceAskTransitionActionStateValidationV0 for TokenPlaceAskTransitionAction { /// A listing needs a token that is not paused and a seller whose account is not frozen and - /// who holds the amount. What needs no read (a tradeable token, a total that fits in an - /// order, an expiry after the block) is checked with the batch's advanced structure. + /// who holds the amount. What needs no read (a tradeable token, the decimals it signs) is + /// checked with the batch's advanced structure. fn validate_state_v0( &self, platform: &PlatformStateRef, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs index 6b0f3e70f5f..e98687e7623 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -1,5 +1,5 @@ use dpp::block::block_info::BlockInfo; -use dpp::consensus::state::token::{TokenOrderDecimalsMismatchError, TokenOrderExpiredError}; +use dpp::consensus::state::token::TokenOrderDecimalsMismatchError; 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::validation::SimpleConsensusValidationResult; @@ -20,14 +20,14 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch) } impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransitionAction { - /// What a listing needs that reads nothing beyond the contract the action carries and the - /// block: a tradeable token, the decimals it was signed at being the token's (its price is - /// per whole token, so a change of the token's decimals between signing and execution would - /// reprice it), and, when it expires, an expiry after the block. What the listing is worth - /// at the decimals it signs is checked with the transition's structure. + /// What a listing needs that reads nothing beyond the contract the action carries: a + /// tradeable token, and the decimals it was signed at being the token's (its price is per + /// whole token, so a change of the token's decimals between signing and execution would + /// reprice it). What the listing is worth at the decimals it signs is checked with the + /// transition's structure. fn validate_structure_v0( &self, - block_info: &BlockInfo, + _block_info: &BlockInfo, _platform_version: &PlatformVersion, ) -> Result { let validation_result = validate_token_tradeable(self.base())?; @@ -48,15 +48,6 @@ impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransit )); } - if let Some(expires_at) = self.expires_at() { - if expires_at <= block_info.time_ms { - return Ok(SimpleConsensusValidationResult::new_with_error( - TokenOrderExpiredError::new(self.order_id(), expires_at, block_info.time_ms) - .into(), - )); - } - } - Ok(SimpleConsensusValidationResult::new()) } } 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 index 69c7786ae73..1833388def8 100644 --- 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 @@ -38,8 +38,8 @@ impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { /// /// - a `TokenConfigUpdate` that makes a token tradeable needs a transferable token /// (`NonTransferableTokenTradeableError`, 10281); - /// - a `TokenPlaceAsk` needs a tradeable token, a total that fits in an order and an expiry - /// after the block, and a `TokenFillAsk` a token that is still tradeable. + /// - a `TokenPlaceAsk` needs a tradeable token and the token's decimals, and a + /// `TokenFillAsk` a token that is still tradeable. /// /// A failing action bumps the signer's nonce, as every advanced structure failure does. fn validate_advanced_structure_from_state_v2( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 03a19d990b7..33fba4d9b10 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -286,7 +286,6 @@ impl Market { amount: u64, price: Credits, min_fill: u64, - expires_at: Option, ) -> (StateTransition, Identifier) { let platform_version = PlatformVersion::latest(); let decimals = self.token_decimals(); @@ -301,7 +300,6 @@ impl Market { price, decimals, min_fill, - expires_at, &self.seller.key, nonce, 0, @@ -488,7 +486,6 @@ impl Market { price, decimals, 1, - None, &self.buyer.key, nonce, 0, @@ -594,7 +591,6 @@ impl Market { PRICE, 8, 1, - None, &self.seller.key, nonce, 0, @@ -801,7 +797,7 @@ impl Market { #[tokio::test] async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100, Some(9_000)).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( @@ -821,7 +817,6 @@ async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { amount: 1_000, remaining: 1_000, min_fill: 100, - expires_at: Some(9_000), escrow: 0, decimals: 8, } @@ -883,7 +878,7 @@ fn with_base( #[tokio::test] async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_not_derive() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let mut two_tokens = market.contract.clone(); let token = market @@ -917,7 +912,7 @@ async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_ #[tokio::test] async fn should_not_prove_a_listing_carrying_group_information() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); assert_matches!( @@ -938,7 +933,7 @@ async fn should_not_prove_a_listing_carrying_group_information() { #[tokio::test] async fn should_prove_a_listing_after_it_is_partly_filled() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 400, 4_000).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -968,7 +963,7 @@ async fn should_prove_a_listing_after_it_is_partly_filled() { #[tokio::test] async fn should_sell_part_of_a_listing_and_then_the_rest() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); let buyer_credits = market.credit_balance(market.buyer.id()); @@ -1025,7 +1020,7 @@ async fn should_sell_part_of_a_listing_and_then_the_rest() { #[tokio::test] async fn should_fill_a_listing_twice_in_one_block() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); @@ -1068,7 +1063,7 @@ async fn should_fill_a_listing_twice_in_one_block() { #[tokio::test] async fn should_charge_each_fill_the_change_in_the_running_total() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(7, 150_000_000, 1, None).await; + let (place, order_id) = market.place_ask(7, 150_000_000, 1).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); @@ -1098,7 +1093,7 @@ async fn should_charge_each_fill_the_change_in_the_running_total() { #[tokio::test] async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(3, 50_000_000, 1, None).await; + let (place, order_id) = market.place_ask(3, 50_000_000, 1).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 1, 1).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -1144,7 +1139,7 @@ async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { async fn should_refuse_a_listing_amount_above_i64_max() { let mut market = tradeable_market(); let seller_credits = market.credit_balance(market.seller.id()); - let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1, 1, None).await; + let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1, 1).await; assert_matches!( market.check_tx(&place).as_slice(), [ConsensusError::BasicError( @@ -1179,7 +1174,7 @@ async fn should_charge_the_fill_that_widens_a_listings_revision() { }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(200, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(200, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let mut fills_adding_storage = vec![]; @@ -1232,7 +1227,7 @@ async fn should_charge_the_fill_that_widens_a_listings_revision() { #[tokio::test] async fn should_return_the_tokens_of_a_cancelled_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 250, 2_500).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -1295,7 +1290,6 @@ async fn should_charge_a_listing_the_hash_of_its_id_where_it_is_derived() { price: PRICE, decimals: 8, min_fill: 1, - expires_at: None, }; TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( &market.platform.drive, @@ -1362,7 +1356,7 @@ async fn should_charge_a_listing_refused_after_its_id_is_derived_the_hash() { #[tokio::test] async fn should_refuse_listing_a_token_that_is_not_tradeable() { let mut market = market_with(|_| {}, dash_to_credits!(1.0)); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; // The rule reads only the contract the action carries, so the mempool refuses it too. assert_matches!( @@ -1485,7 +1479,7 @@ async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); market.freeze_the_buyer().await; @@ -1517,7 +1511,7 @@ async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to #[tokio::test] async fn should_refuse_listing_more_than_the_seller_holds() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE, 1).await; let processing_result = market.process(&place); assert_matches!( @@ -1546,7 +1540,7 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { dash_to_credits!(1.0), ); let seller_credits = market.credit_balance(market.seller.id()); - let (place, order_id) = market.place_ask(2, i64::MAX as u64, 1, None).await; + let (place, order_id) = market.place_ask(2, i64::MAX as u64, 1).await; assert_matches!( market.check_tx(&place).as_slice(), [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] @@ -1565,48 +1559,10 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { assert_eq!(market.listing(order_id), None); } -#[tokio::test] -async fn should_refuse_a_listing_that_expires_before_the_block() { - let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(5_000)).await; - let processing_result = market.process_at(&place, &at_time(5_000)); - - assert_matches!( - processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::PaidConsensusError { - error: ConsensusError::StateError(StateError::TokenOrderExpiredError(inner)), - .. - }] if inner.order_id() == order_id - ); - assert_eq!(market.listing(order_id), None); -} - -#[tokio::test] -async fn should_refuse_filling_an_expired_listing_but_still_cancel_it() { - let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(5_000)).await; - market.assert_executes(&place, &at_time(1_000)); - - let fill = market.fill_ask(order_id, 1, 1_000, 10_000).await; - let processing_result = market.process_at(&fill, &at_time(5_000)); - assert_matches!( - processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::PaidConsensusError { - error: error @ ConsensusError::StateError(StateError::TokenOrderExpiredError(_)), - .. - }] if error.code() == 40752 - ); - assert_eq!(market.token_balance(market.buyer.id()), None); - - let cancel = market.cancel(order_id, false).await; - market.assert_executes(&cancel, &at_time(5_000)); - assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); -} - #[tokio::test] async fn should_refuse_filling_ones_own_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.seller_fill_ask(order_id).await; @@ -1624,7 +1580,7 @@ async fn should_refuse_filling_ones_own_listing() { #[tokio::test] async fn should_refuse_a_fill_above_the_buyers_price_limit() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 400, 3_999).await; @@ -1644,7 +1600,7 @@ async fn should_refuse_a_fill_above_the_buyers_price_limit() { #[tokio::test] async fn should_refuse_a_fill_the_minimum_fill_cannot_meet() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 950, 950, 1_000_000).await; @@ -1683,7 +1639,7 @@ async fn should_refuse_filling_a_listing_that_does_not_exist() { #[tokio::test] async fn should_refuse_cancelling_someone_elses_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let cancel = market.cancel(order_id, true).await; @@ -1701,7 +1657,7 @@ async fn should_refuse_cancelling_someone_elses_listing() { #[tokio::test] async fn should_refuse_a_fill_of_a_paused_token_but_still_cancel() { let mut market = market_with(make_token_tradeable_and_pausable, dash_to_credits!(1.0)); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); market.pause().await; @@ -1728,10 +1684,10 @@ async fn should_not_fill_a_listing_the_buyer_cannot_pay_for() { let mut market = market_with(make_token_tradeable, dash_to_credits!(0.0001)); let buyer_credits = market.credit_balance(market.buyer.id()); // 10,000,000 credits for the whole listing: the buyer's balance, short of the fee. - let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000, 1, None).await; + let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000, 1).await; market.assert_executes(&place, &BlockInfo::default()); // 20,000,000 credits: twice the buyer's balance. - let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000, 1, None).await; + let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000, 1).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!(buyer_credits, 10_000_000); let buyer_nonce = market.contract_nonce(market.buyer.id()); @@ -1779,7 +1735,7 @@ async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( market.listing(order_id).map(|listing| listing.decimals), @@ -1809,7 +1765,7 @@ async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { assert_eq!((listing.remaining, listing.decimals), (800, 8)); // A listing placed after the change records the new decimals. - let (place, newer) = market.place_ask(10, PRICE, 1, None).await; + let (place, newer) = market.place_ask(10, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( market.listing(newer).map(|listing| listing.decimals), @@ -1818,46 +1774,6 @@ async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { market.assert_supply_balanced(); } -/// A listing's expiry is stored as a signed 8-byte value: `i64::MAX` is a valid expiry, and one -/// more is refused by the structure validation, in check_tx and in a block, before anything is -/// charged. -#[tokio::test] -async fn should_refuse_a_listing_expiry_above_i64_max() { - let mut market = tradeable_market(); - let latest = i64::MAX as u64; - - let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(latest)).await; - assert!(market.check_tx(&place).is_empty()); - market.assert_executes(&place, &BlockInfo::default()); - assert_eq!( - market - .listing(order_id) - .and_then(|listing| listing.expires_at), - Some(latest) - ); - - let seller_credits = market.credit_balance(market.seller.id()); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, Some(latest + 1)).await; - assert_matches!( - market.check_tx(&place).as_slice(), - [ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(error))] - if error.expires_at() == latest + 1 - ); - let processing_result = market.process(&place); - assert_matches!( - processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderExpiryError(_)) - )] if error.code() == 10464 - ); - assert_eq!(market.listing(order_id), None); - assert_eq!(market.credit_balance(market.seller.id()), seller_credits); - assert_eq!( - market.token_balance(market.seller.id()), - Some(BASE_SUPPLY - 1_000) - ); -} - /// A listing naming a token its contract does not define is refused with the token base /// rules' error, charged, in check_tx and in a block: a position past the contract's last /// token, and a contract without tokens (DPNS). @@ -1905,7 +1821,7 @@ async fn should_refuse_a_listing_of_a_token_the_contract_does_not_define() { #[tokio::test] async fn should_refuse_a_fill_of_a_token_the_contract_does_not_define() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index 6b109462896..fe1baa5e066 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use grovedb::TransactionArg; use dpp::block::block_info::BlockInfo; use dpp::consensus::state::token::{ - TokenOrderExpiredError, TokenOrderFillAmountError, TokenOrderNotFoundError, + TokenOrderFillAmountError, TokenOrderNotFoundError, TokenOrderPriceLimitError, TokenOrderSelfFillError, }; use dpp::consensus::ConsensusError; @@ -26,8 +26,8 @@ use crate::state_transition_action::batch::BatchedTransitionAction; impl TokenFillAskTransitionActionV0 { /// Converts a borrowed `TokenFillAskTransitionV0` into an action: reads the listing, and /// works out the amount the fill takes and the credits the buyer pays. A token its contract - /// does not define, a listing that is not open for this token, the buyer's own listing, an - /// expired listing, a fill range the listing cannot meet and a cost above the buyer's limit + /// does not define, a listing that is not open for this token, the buyer's own listing, a + /// fill range the listing cannot meet and a cost above the buyer's limit /// each bump the nonce with their error instead. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( @@ -129,17 +129,6 @@ impl TokenFillAskTransitionActionV0 { fee_result, ); } - if let Some(expires_at) = order.expires_at { - if order.is_expired_at(block_info.time_ms) { - return bump( - vec![ - TokenOrderExpiredError::new(*order_id, expires_at, block_info.time_ms) - .into(), - ], - fee_result, - ); - } - } let Some(fill_amount) = order.fill_amount(*min_amount, *max_amount) else { return bump( vec![TokenOrderFillAmountError::new( diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs index 5f6973b3a70..20ef3f2c97a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs @@ -3,7 +3,6 @@ use dpp::balances::credits::TokenAmount; use dpp::document::Document; use dpp::fee::Credits; use dpp::identifier::Identifier; -use dpp::prelude::TimestampMillis; use std::sync::Arc; /// transformer module for the token place ask transition action @@ -59,12 +58,6 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionAction } } - fn expires_at(&self) -> Option { - match self { - TokenPlaceAskTransitionAction::V0(v0) => v0.expires_at, - } - } - fn decimals(&self) -> u8 { match self { TokenPlaceAskTransitionAction::V0(v0) => v0.decimals, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs index a985f8034f8..764c6b2ac26 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs @@ -6,7 +6,6 @@ use dpp::balances::credits::TokenAmount; use dpp::document::Document; use dpp::fee::Credits; use dpp::identifier::Identifier; -use dpp::prelude::TimestampMillis; use std::sync::Arc; /// Token place ask transition action v0 @@ -22,8 +21,6 @@ pub struct TokenPlaceAskTransitionActionV0 { pub price: Credits, /// The smallest fill the seller accepts unless it takes everything open pub min_fill: TokenAmount, - /// The block time from which the listing can no longer be filled - pub expires_at: Option, /// The token's decimals the listing was signed at, which its price is at and its order /// records; the structure validation refuses them unless they are the token's pub decimals: u8, @@ -53,9 +50,6 @@ pub trait TokenPlaceAskTransitionActionAccessorsV0 { /// The smallest fill the seller accepts fn min_fill(&self) -> TokenAmount; - /// The block time from which the listing can no longer be filled - fn expires_at(&self) -> Option; - /// The token's decimals the listing was signed at fn decimals(&self) -> u8; @@ -106,10 +100,6 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionActionV self.min_fill } - fn expires_at(&self) -> Option { - self.expires_at - } - fn decimals(&self) -> u8 { self.decimals } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index c71ab0375d0..062eaa0258a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -50,7 +50,6 @@ impl TokenPlaceAskTransitionActionV0 { price, decimals, min_fill, - expires_at, } = value; let mut drive_operations = vec![]; @@ -117,7 +116,6 @@ impl TokenPlaceAskTransitionActionV0 { amount: *amount, remaining: *amount, min_fill: *min_fill, - expires_at: *expires_at, escrow: 0, decimals, } @@ -131,7 +129,6 @@ impl TokenPlaceAskTransitionActionV0 { amount: *amount, price: *price, min_fill: *min_fill, - expires_at: *expires_at, decimals, order_document, token_market_contract: drive 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 133b71b70d2..3aa99b3ae48 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 @@ -1398,7 +1398,6 @@ impl Drive { || order.remaining == 0 || order.remaining > order.amount || order.min_fill != token_place_ask_transition.min_fill() - || order.expires_at != token_place_ask_transition.expires_at() { return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( "proof of the listing {} does not hold the terms of state transition (token place ask)", diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 4cf0844be9e..061e35f8ec9 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -165,10 +165,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds /// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, /// as software that cannot decode them refuses them. The checks that read nothing beyond the -/// contract and the block (a tradeable token, its decimals, a listing's expiry) run with the batch +/// contract (a tradeable token, its decimals) run with the batch /// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. -/// A listing's terms, its expiry included (at most `i64::MAX`, `InvalidTokenOrderExpiryError` -/// 10464) and what it is worth at the decimals it signs (at most `i64::MAX` credits, +/// A listing's terms and what it is worth at the decimals it signs (at most `i64::MAX` credits, /// `InvalidTokenOrderValueError` 10465), are checked with the transition's structure, unpaid. A listing signs the decimals /// its price is at and is refused unless they are the token's (`TokenOrderDecimalsMismatchError` /// 40758, with the batch `advanced_structure`); it records them and its fills are priced at diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index d772d1997c6..c787b75635d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -606,9 +606,6 @@ fn summarize_token_transition( t.decimals() ))?); lines.push(budget.write(format_args!("minimum fill: {}", t.min_fill()))?); - if let Some(expires_at) = t.expires_at() { - lines.push(budget.write(format_args!("expires at: {expires_at} ms"))?); - } (Some(t.amount()), None, None, None) } // A fill spends at most `max_total_price` credits for at most `max_amount` tokens. @@ -1034,7 +1031,6 @@ mod tests { price: 1_000_000_000, decimals, min_fill: 1, - expires_at: None, }, ))) }; diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 512b6e837e0..25ec2352f8f 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -7,13 +7,12 @@ use crate::errors::consensus::basic::{ UnsupportedProtocolVersionErrorWasm, UnsupportedVersionErrorWasm, }; use dpp::consensus::basic::token::{ - InvalidTokenOrderExpiryError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, - InvalidTokenOrderValueError, + InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, InvalidTokenOrderValueError, }; use dpp::consensus::state::token::{ - TokenNotTradeableError, TokenOrderDecimalsMismatchError, TokenOrderExpiredError, - TokenOrderFillAmountError, TokenOrderNotFoundError, TokenOrderNotOwnedError, - TokenOrderPriceLimitError, TokenOrderSelfFillError, + TokenNotTradeableError, TokenOrderDecimalsMismatchError, TokenOrderFillAmountError, + TokenOrderNotFoundError, TokenOrderNotOwnedError, TokenOrderPriceLimitError, + TokenOrderSelfFillError, }; use dpp::consensus::ConsensusError as DPPConsensusError; @@ -542,9 +541,6 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenOrderNotFoundError(e) => { generic_consensus_error!(TokenOrderNotFoundError, e).into() } - StateError::TokenOrderExpiredError(e) => { - generic_consensus_error!(TokenOrderExpiredError, e).into() - } StateError::TokenOrderFillAmountError(e) => { generic_consensus_error!(TokenOrderFillAmountError, e).into() } @@ -1463,9 +1459,6 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidTokenOrderFillRangeError(e) => { generic_consensus_error!(InvalidTokenOrderFillRangeError, e).into() } - BasicError::InvalidTokenOrderExpiryError(e) => { - generic_consensus_error!(InvalidTokenOrderExpiryError, e).into() - } BasicError::InvalidTokenOrderValueError(e) => { generic_consensus_error!(InvalidTokenOrderValueError, e).into() } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs index 65949f6341f..7085068e6af 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -2,10 +2,9 @@ use crate::error::WasmDppResult; use crate::impl_try_from_js_value; use crate::impl_wasm_type_info; use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; -use crate::utils::{try_from_options, try_from_options_optional_with, try_from_options_with, try_to_u64, try_to_u8}; +use crate::utils::{try_from_options, try_from_options_with, try_to_u64, try_to_u8}; use dpp::balances::credits::TokenAmount; use dpp::fee::Credits; -use dpp::prelude::TimestampMillis; use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; use dpp::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; @@ -17,7 +16,7 @@ const TOKEN_PLACE_ASK_OPTIONS_TS: &str = r#" /** * Lists `amount` of the token for sale at `price` credits per whole token. The tokens move into * escrow until the listing is filled or cancelled. A fill takes at least `minFill`, unless it - * takes everything left. A listing with `expiresAt` can no longer be filled from that block time. + * takes everything left. */ export interface TokenPlaceAskTransitionOptions { base: TokenBaseTransition; @@ -26,7 +25,6 @@ export interface TokenPlaceAskTransitionOptions { /** The token's decimals the price is at; the listing is refused unless they are the token's. */ decimals: number; minFill: bigint; - expiresAt?: bigint; } "#; @@ -71,9 +69,6 @@ impl TokenPlaceAskTransitionWasm { let min_fill: TokenAmount = try_from_options_with(&options, "minFill", |v| try_to_u64(v, "minFill"))?; - let expires_at: Option = - try_from_options_optional_with(&options, "expiresAt", |v| try_to_u64(v, "expiresAt"))?; - Ok(TokenPlaceAskTransitionWasm(TokenPlaceAskTransition::V0( TokenPlaceAskTransitionV0 { base: base.into(), @@ -81,7 +76,6 @@ impl TokenPlaceAskTransitionWasm { price, decimals, min_fill, - expires_at, }, ))) } @@ -111,11 +105,6 @@ impl TokenPlaceAskTransitionWasm { self.0.min_fill() } - #[wasm_bindgen(getter = expiresAt)] - pub fn expires_at(&self) -> Option { - self.0.expires_at() - } - #[wasm_bindgen(setter = base)] pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { self.0.set_base(base.into()) @@ -149,18 +138,6 @@ impl TokenPlaceAskTransitionWasm { self.0.set_min_fill(try_to_u64(min_fill, "minFill")?); Ok(()) } - - #[wasm_bindgen(setter = expiresAt)] - pub fn set_expires_at( - &mut self, - #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, - ) -> WasmDppResult<()> { - let expires_at = expires_at - .map(|value| try_to_u64(&value, "expiresAt")) - .transpose()?; - self.0.set_expires_at(expires_at); - Ok(()) - } } impl_try_from_js_value!(TokenPlaceAskTransitionWasm, "TokenPlaceAskTransition"); diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index 63dab9c0e9f..7a33cba908a 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -790,7 +790,6 @@ describe('TokenTransitions', () => { price: BigInt(100), decimals: 8, minFill: BigInt(1), - expiresAt: BigInt(5000), }); it('should set the integer terms', () => { @@ -799,21 +798,15 @@ describe('TokenTransitions', () => { transition.amount = BigInt(2000); transition.price = BigInt(200); transition.minFill = BigInt(10); - transition.expiresAt = BigInt(6000); expect(transition.amount).to.equal(BigInt(2000)); expect(transition.price).to.equal(BigInt(200)); expect(transition.minFill).to.equal(BigInt(10)); - expect(transition.expiresAt).to.equal(BigInt(6000)); expect(transition.decimals).to.equal(8); transition.decimals = 2; expect(transition.decimals).to.equal(2); - - transition.expiresAt = undefined; - - expect(transition.expiresAt).to.be.undefined(); }); it('should refuse decimals outside a byte and keep the previous ones', () => { @@ -834,7 +827,7 @@ describe('TokenTransitions', () => { })).to.throw(); }); - ['amount', 'price', 'minFill', 'expiresAt'].forEach((term) => { + ['amount', 'price', 'minFill'].forEach((term) => { it(`should refuse an out-of-range ${term} and keep the previous one`, () => { const transition = listing(); const previous = transition[term]; From 5fe8a035b4d9738e35ceffe251df33129c58c79a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 19:57:52 +0300 Subject: [PATCH 062/121] docs(platform): a fill's absolute escrow write depends on the one-transition batch cap SystemLimits::max_transitions_in_documents_batch lists the marketplace fill among what raising the cap needs: a fill reads its order as state holds it and writes remaining and escrow as absolute values, so two fills of one order in a batch would both read it unfilled. The order id preimage size names std::mem::size_of explicitly. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/tokens/token_order_side.rs | 2 +- .../token_fill_ask_transition_action/v0/transformer.rs | 5 +++++ .../rs-platform-version/src/version/system_limits/mod.rs | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/tokens/token_order_side.rs b/packages/rs-dpp/src/tokens/token_order_side.rs index 14164d825ee..bcc1bb962fb 100644 --- a/packages/rs-dpp/src/tokens/token_order_side.rs +++ b/packages/rs-dpp/src/tokens/token_order_side.rs @@ -88,6 +88,6 @@ impl TokenOrderSide { /// The number of bytes `order_id` hashes (twice, with SHA-256): the token id and the maker's /// id (32 bytes each), the document type name and the nonce. pub fn order_id_preimage_size(&self) -> usize { - 2 * 32 + self.document_type_name().len() + size_of::() + 2 * 32 + self.document_type_name().len() + std::mem::size_of::() } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index fe1baa5e066..a80a53c6c95 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -29,6 +29,11 @@ impl TokenFillAskTransitionActionV0 { /// does not define, a listing that is not open for this token, the buyer's own listing, a /// fill range the listing cannot meet and a cost above the buyer's limit /// each bump the nonce with their error instead. + /// + /// The listing is read as state holds it, and the fill's operations write its new + /// `remaining` and escrow as absolute values. That is sound only while a batch carries one + /// transition (`SystemLimits::max_transitions_in_documents_batch`): two fills of one listing + /// in a batch would both read it unfilled. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( drive: &Drive, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index a45e349727f..4a5650af98d 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -151,6 +151,12 @@ pub struct SystemLimits { /// then start refusing batches that fee validation would have executed. Recheck the /// inequality rather than assuming it. /// + /// * A token marketplace fill (protocol version 15) reads its order from state and writes + /// an absolute `remaining` and escrow. Two fills of one order in one batch would both read + /// the order as the batch found it, release more than its escrow held, and write a stale + /// `remaining`. Raising the cap needs fills transformed against a batch-local order state + /// (or a refusal of an order filled or cancelled twice in a batch). + /// /// Five cases in `rs-drive`'s `batched_group_drain` suite are `#[ignore]`d for exactly this /// reason; the rest of that suite runs. Anyone raising this cap should un-ignore those five /// first and make them pass. From 26c35af156a9c517385f772f5b76b6102b644c49 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 23:16:26 +0300 Subject: [PATCH 063/121] fix(drive): order reads add their cost with checked fee arithmetic The fill and cancel transformations add the order document read through FeeResult::checked_add_assign rather than a saturating add, so an overflow is an error instead of an undercharge. The fill's payment to the seller notes why it cannot pass MAX_CREDITS. Co-Authored-By: Claude Opus 5.5 --- .../batch/token/token_fill_ask_transition.rs | 5 +++++ .../token_cancel_order_transition_action/v0/transformer.rs | 2 +- .../token_fill_ask_transition_action/v0/transformer.rs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs index 64587959831..0e05368062f 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs @@ -55,6 +55,11 @@ impl DriveHighLevelBatchOperationConverter for TokenFillAskTransitionAction { }), ]; + // The buyer's credits move to the seller, so the seller's balance cannot pass + // `MAX_CREDITS`: no identity holds more than all the credits on Platform, which + // the total credits check keeps far below it (every credit comes from Core, at + // most about 2.1e18 against 9.2e18). A direct purchase pays its contract owner + // the same way. if credits > 0 { ops.push(IdentityOperation( IdentityOperationType::RemoveFromIdentityBalance { diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs index 0f2cec6e4a2..f6f6b6309d7 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs @@ -96,7 +96,7 @@ impl TokenCancelOrderTransitionActionV0 { transaction, platform_version, )?; - fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; let Some(order_document) = order_document else { return bump( diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index a80a53c6c95..a0ba46d7c2b 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -113,7 +113,7 @@ impl TokenFillAskTransitionActionV0 { transaction, platform_version, )?; - fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; let Some(order_document) = order_document else { return bump( From b97c64614e037d62a218d9c251700b444d1fa1cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 17:27:52 +0300 Subject: [PATCH 064/121] 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 1f9875c1722121c9903d8eb0acce8f5c9e2a0481 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 17:28:18 +0300 Subject: [PATCH 065/121] docs(platform-version): renumber the token listings note after the merge 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 ce367c59ca5..4c27b6ae1b1 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -169,7 +169,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// -/// 7. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// 8. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount /// of a tradeable token at a price in credits per whole token and moves it into escrow), /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds From d99411dec935fc640634ffc1e13add1efbb25554 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 17:50:44 +0300 Subject: [PATCH 066/121] docs(platform-version): renumber the Token listings note after the merge 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 0cd0de751f8..dd082e65f4c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -183,7 +183,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// -/// 8. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// 9. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount /// of a tradeable token at a price in credits per whole token and moves it into escrow), /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds From 436c30cab7b9edd7562e1ce4619ec9720b8609cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 18:00:49 +0300 Subject: [PATCH 067/121] 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 c15044a1096ba7a867704b77639fbbc3c1b952a0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 19:25:17 +0300 Subject: [PATCH 068/121] fix(dpp): orders of tokens with 39 or more decimals are priced, not refused A token's decimals are a u8, but the order arithmetic needed 10^decimals to fit in u128, so every order of a token with 39 or more decimals was refused as worth more than an order holds. From 39 decimals a whole token is more base units than any amount times any price, so an order is now worth one credit rounded up and nothing rounded down. Co-Authored-By: Claude Opus 5.5 --- .../validate_structure/mod.rs | 19 ++++++ packages/rs-dpp/src/tokens/token_order.rs | 58 +++++++++++++++---- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs index 461016fe39c..6abf355f3a1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -57,6 +57,25 @@ mod tests { .errors } + /// A listing of a token with 39 or more decimals is worth one credit at most, so terms at + /// their bounds pass the structure validation there too. + #[test] + fn should_accept_a_listing_of_a_token_with_39_or_more_decimals() { + for decimals in [39, 255] { + let errors = TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { + base: Default::default(), + amount: SIGNED_MAX, + price: SIGNED_MAX, + decimals, + min_fill: 1, + }) + .validate_structure(PlatformVersion::latest()) + .expect("expected the structure validation to run") + .errors; + assert!(errors.is_empty(), "decimals {decimals}: {errors:?}"); + } + } + /// A listing's amount and price are stored as signed 8-byte values, so each is 1 to /// `i64::MAX`. At 8 decimals, `i64::MAX + 1` base units at 1 credit and 1 base unit at /// `i64::MAX + 1` credits are both worth what an order holds, so only these bounds refuse diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs index 33db4b69350..7da1cde3fe4 100644 --- a/packages/rs-dpp/src/tokens/token_order.rs +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -56,19 +56,24 @@ pub struct TokenOrder { pub decimals: u8, } -fn unit(decimals: u8) -> Result { - 10u128.checked_pow(decimals as u32).ok_or_else(|| { - ProtocolError::Overflow("10 to the power of the token's decimals overflows u128") - }) +/// 10 to the power of `decimals`, the base units in one whole token, or `None` above +/// `u128::MAX` (39 decimals or more). A token's decimals are a `u8`, so the unit may not fit. +fn unit(decimals: u8) -> Option { + 10u128.checked_pow(decimals as u32) } -fn total_at(filled: TokenAmount, price: Credits, unit: u128, round_up: bool) -> u128 { +/// What `filled` base units at `price` credits per whole token cost, rounded up or down, with +/// `unit` base units in a whole token (`None` when that is more than `u128` holds). +fn total_at(filled: TokenAmount, price: Credits, unit: Option, round_up: bool) -> u128 { // At most `i64::MAX * i64::MAX`, under `u128::MAX`. let value = filled as u128 * price as u128; - if round_up { - value.div_ceil(unit) - } else { - value / unit + match unit { + Some(unit) if round_up => value.div_ceil(unit), + Some(unit) => value / unit, + // A whole token is more base units than `u128` holds, so more than `value`: a nonzero + // total is under one credit, 1 rounded up and 0 rounded down. + None if round_up => u128::from(value > 0), + None => 0, } } @@ -110,7 +115,7 @@ impl TokenOrder { /// what an offer's seller receives, rounded down, as the change in the order's running total /// at the order's own `decimals`. pub fn fill_credits(&self, fill_amount: TokenAmount) -> Result { - let unit = unit(self.decimals)?; + let unit = unit(self.decimals); let filled = self.filled(); let filled_after = filled .checked_add(fill_amount) @@ -128,8 +133,7 @@ impl TokenOrder { /// up, for a token with `decimals` decimals: everything its buyers pay for a listing, and the /// escrow an offer holds. `None` when it is more credits than an order holds (`i64::MAX`). pub fn order_value(amount: TokenAmount, price: Credits, decimals: u8) -> Option { - let unit = unit(decimals).ok()?; - let value = total_at(amount, price, unit, true); + let value = total_at(amount, price, unit(decimals), true); if value > i64::MAX as u128 { None } else { @@ -358,6 +362,36 @@ mod tests { ); } + /// A token's decimals are a \`u8\`. From 39 decimals a whole token is more base units than + /// \`u128\` holds, and more than any amount times any price, so an order is worth one credit + /// rounded up (a listing's first fill pays it) and nothing rounded down (an offer's sales + /// are paid nothing). + #[test] + fn should_price_orders_of_tokens_with_39_or_more_decimals() { + let max = i64::MAX as u64; + for decimals in [39, 255] { + assert_eq!(TokenOrder::order_value(1, 1, decimals), Some(1)); + assert_eq!(TokenOrder::order_value(max, max, decimals), Some(1)); + + let mut listing = order(TokenOrderSide::Ask, 1_000, 1_000, 1); + listing.decimals = decimals; + listing.price = max; + assert_eq!(listing.fill_credits(400).ok(), Some(1)); + listing.remaining = 600; + assert_eq!(listing.fill_credits(600).ok(), Some(0)); + + let mut offer = order(TokenOrderSide::Bid, 1_000, 1_000, 1); + offer.decimals = decimals; + offer.price = max; + assert_eq!(offer.fill_credits(1_000).ok(), Some(0)); + } + // 38 decimals still fit: 10^38 base units in a whole token. + assert_eq!( + TokenOrder::order_value(max, max, 38), + Some((max as u128 * max as u128).div_ceil(10u128.pow(38)) as u64) + ); + } + #[test] fn should_round_trip_through_its_document() { let mut offer = order(TokenOrderSide::Bid, 1000, 400, 10); From 2eb9666c1b6b8ae024f18c76f84f1aa8c1a87120 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 01:11:48 +0300 Subject: [PATCH 069/121] 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 848d3bcba99039f4b800271ea23ee98dd1dc5dbd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 01:12:11 +0300 Subject: [PATCH 070/121] docs(platform-version): note 9 records how orders of tokens with 39 or more decimals are priced Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index b6b0d27fe99..899306aafc9 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -196,7 +196,10 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// its price is at and is refused unless they are the token's (`TokenOrderDecimalsMismatchError` /// 40758, with the batch `advanced_structure`); it records them and its fills are priced at /// them; a listing of a token its contract does not define bumps the nonce with the token base -/// rules' error. Their structure and state +/// rules' error. An order of a token with 39 decimals or more (decimals are a `u8`, and a +/// `Conventions` update is not held to the 16 a contract is) has a whole token of more base +/// units than any amount times any price: it is worth one credit rounded up, so a listing's +/// first fill pays 1 and the rest 0, and nothing rounded down. Their structure and state /// validations and their high-level operation converters are 0 in every table, which only this /// version reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { From a5f949896d35a3d567c5b1aca5bc43a813c8dca6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 08:57:56 +0300 Subject: [PATCH 071/121] 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 072/121] 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 073/121] 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 074/121] 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 075/121] 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 076/121] 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 077/121] 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 078/121] 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 079/121] 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 080/121] 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 21fa8b1516ff33991d210f52e542f0da0fb8ecd1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 13:01:19 +0300 Subject: [PATCH 081/121] docs(platform-version): the listings note is note 10 of protocol version 15 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 5a1451b4424..34bf210977e 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -206,7 +206,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// -/// 9. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// 10. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount /// of a tradeable token at a price in credits per whole token and moves it into escrow), /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds From 22a416216e18836df0c3d222094f9a777a3531ba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 13:23:59 +0300 Subject: [PATCH 082/121] feat(platform)!: a listing takes its token's minimum fill and decimals The token's trade mode carries the minimum fill (Tradeable { minFill }), which every fill reads as the token has it then; a listing of less is refused. A listing no longer signs minFill or decimals: it records the token's decimals when placed, as the token market contract's generatedFrom declares. The value check moves to the batch's advanced structure, which knows the token. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 49 ++-- .../services/tokens/TokenMaterializer.kt | 13 +- .../tokens/TokenMaterializerTradeModeTest.kt | 55 ++++ .../persistence/entities/TokenEntity.kt | 7 +- .../token_configuration/mod.rs | 59 ++++- .../token_marketplace_rules/mod.rs | 50 ++-- .../token_marketplace_rules/v0/mod.rs | 30 ++- .../invalid_token_order_fill_range_error.rs | 5 +- .../batched_transition/mod.rs | 2 +- .../batched_transition/token_order_terms.rs | 48 +++- .../token_place_ask_transition/v0/mod.rs | 17 +- .../v0/v0_methods.rs | 25 -- .../token_place_ask_transition/v0_methods.rs | 24 -- .../validate_structure/mod.rs | 24 +- .../validate_structure/v0/mod.rs | 7 +- .../document/batch_transition/methods/mod.rs | 4 - .../batch_transition/methods/v1/mod.rs | 2 - .../batch_transition/v1/v1_methods.rs | 4 - packages/rs-dpp/src/tokens/token_order.rs | 75 +++--- .../processor/traits/is_allowed.rs | 35 +-- .../structure_v0/mod.rs | 32 ++- .../batch/tests/token/marketplace/mod.rs | 243 +++++++++++------- .../batch/tests/token/non_transferable/mod.rs | 4 +- .../contract_structure_error_tests.rs | 2 +- .../contract_structure_error_tests.rs | 2 +- .../v0/transformer.rs | 7 +- .../token_place_ask_transition_action/mod.rs | 6 - .../v0/mod.rs | 16 +- .../v0/transformer.rs | 23 +- .../v0/mod.rs | 5 +- .../rs-platform-version/src/version/v15.rs | 22 +- .../network/state_transition_summary.rs | 48 +--- .../Core/Utils/DataContractParser.swift | 17 +- .../Persistence/Types/TokenTypes.swift | 10 +- .../DataContractParserTradeModeTests.swift | 34 ++- .../token_transitions/token_place_ask.rs | 45 +--- .../src/tokens/configuration/trade_mode.rs | 21 +- .../tests/unit/TokenConfiguration.spec.ts | 9 +- .../tests/unit/TokensTransitions.spec.ts | 29 +-- 39 files changed, 600 insertions(+), 510 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 7d2f1a2553b..78fc3b63f6c 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -11,12 +11,12 @@ price and each purchase mints new tokens and pays the contract owner. ## Making a token tradeable -A token opts in through its `marketplaceRules`: +A token opts in through its `marketplaceRules`, with its minimum fill: ```json "marketplaceRules": { "$formatVersion": "0", - "tradeMode": "Tradeable", + "tradeMode": { "Tradeable": { "minFill": 1000 } }, "tradeModeChangeRules": { "$formatVersion": "0", "authorizedToMakeChange": { "$type": "contractOwner" }, @@ -28,6 +28,16 @@ A token opts in through its `marketplaceRules`: } ``` +`minFill` is the smallest amount, in base units, a fill of one of the token's orders takes, +unless it takes everything the order has left, and the least a fill leaves open: no order is +ever left holding less than a trade. An order of less is refused when it is placed +(`InvalidTokenOrderFillRangeError`, 10463, the minimum and the amount). It is the token's, not the +order's: every fill reads it as the token has it when the fill executes, so raising or lowering it +(a `MarketplaceTradeMode` configuration update, under `tradeModeChangeRules`) applies to the open +orders too. An order already holding less than a raised minimum is still filled, whole. It is from +1 to `i64::MAX` (`InvalidTokenOrderFillRangeError` on contract create or update, or on the +configuration update). + | `tradeMode` | Meaning | |---|---| | `NotTradeable` (the default) | Holders cannot list the token or make offers for it on Platform | @@ -70,13 +80,14 @@ document's `$revision` is a variable-length integer, 7 bits to a byte: a fill th revision past one of those widths (from 127 to 128, then from 16,383 to 16,384, and so on) adds a byte, and no other fill adds storage. -An order's price is in credits per whole token, so the token's decimals set what it is worth. Its -signer signs the decimals the price is at (`decimals`), and the order is refused -(`TokenOrderDecimalsMismatchError`, 40758) unless they are the token's when it executes: a change of -the token's decimals between signing and execution, by whoever controls its conventions, cannot -reprice it. Each order records those decimals (`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 keep each scale apart (`tokenId`, then `decimals`, +An order's price is in credits per whole token, so the token's decimals set what it is worth. The +signer names no decimals: each order records the token's decimals as the token has them when the +order is placed (`decimals`, declared `generatedFrom` `sys.tokens.decimals` of `tokenId`, so it +is generated when the order is created and kept after). 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. Whoever controls the token's conventions can +change its decimals while a signed order waits to execute, and the order is then placed at the new +scale: a listing asks, and an offer escrows, its price per whole token of the new decimals. 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 @@ -104,7 +115,7 @@ Three token transitions of a batch work with listings. Each is refused before pr | Transition | Fields | Effect | |---|---|---| -| `TokenPlaceAsk` | `amount`, `price`, `decimals`, `minFill` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | +| `TokenPlaceAsk` | `amount`, `price` | Moves `amount` base units from the seller's balance into escrow and records an `ask` | | `TokenFillAsk` | `orderId`, `minAmount`, `maxAmount`, `maxTotalPrice` | Moves part or all of the listing from escrow to the buyer, and its cost from the buyer's credits to the seller's | | `TokenCancelOrder` | `orderId`, `side` | Returns what an order holds to its owner and deletes the order | @@ -116,11 +127,10 @@ A listing's id follows from the token, the seller and the transition's identity `price` is in credits per whole token, 10 to the power of the token's decimals base units. The listing records those decimals when it is placed and every fill prices it at them, so a later change to the token's decimals does not change what the rest of the listing costs. A fill -takes as much as it may: everything open, up to `maxAmount`. A listing never keeps less than -`minFill` open, so a fill that would leave less takes less, leaving exactly `minFill`; a fill -that does not take everything open takes at least `minFill`. A `minFill` equal to `amount` makes -the listing all-or-nothing. The fill is refused when the amount it would take is under -`minAmount`, or when it costs more than `maxTotalPrice`. +takes as much as it may: everything open, up to `maxAmount`. A listing never keeps less than the +token's `minFill` open, so a fill that would leave less takes less, leaving exactly `minFill`; a +fill that does not take everything open takes at least `minFill`. The fill is refused when the +amount it would take is under `minAmount`, or when it costs more than `maxTotalPrice`. Each fill pays the change in the listing's running total rounded up, so splitting a purchase never changes what it costs in all: @@ -137,11 +147,11 @@ The rounding favours the seller and is never more than one credit per fill. | Rule | Refused with | |---|---| -| `amount` and `price` from 1 to `i64::MAX`, `minFill` from 1 to `amount`, and what the order is worth at the `decimals` it signs at most `i64::MAX` credits | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462), `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderValueError` (10465) | +| `amount` and `price` from 1 to `i64::MAX` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462) | | A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) | | The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` | | The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) | -| A listing's `decimals` are the token's | `TokenOrderDecimalsMismatchError` (40758) | +| `amount` at least the token's `minFill`, and what the order is worth at the token's decimals at most `i64::MAX` credits | `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderValueError` (10465) | | The order is open, for this token | `TokenOrderNotFoundError` (40751) | | The fill range meets the listing | `TokenOrderFillAmountError` (40753) | | The fill costs at most `maxTotalPrice` | `TokenOrderPriceLimitError` (40754) | @@ -153,8 +163,9 @@ The rounding favours the seller and is never more than one credit per fill. The rules on a transition's own fields (the first two rows) are checked before anything is read and refused unpaid. The state rules are paid: a refused transition bumps the signer's nonce and charges -the work that ran. The rules that read nothing beyond the contract (a tradeable token and the -decimals a listing signs) run with the batch's advanced structure, so check_tx refuses them too. A +the work that ran. The rules that read nothing beyond the contract (a tradeable token, its minimum +fill and what the listing is worth at its decimals) run with the batch's advanced structure, so +check_tx refuses them too. A cancel has no rule beyond ownership, so an order can always be withdrawn, also when the token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are checked with the batch's fees, which count a fill's cost like a direct purchase. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt index 552dd3311f1..c0d0abe04cc 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt @@ -123,7 +123,7 @@ object TokenMaterializer { // Marketplace rules. val marketplace = dict.obj("marketplaceRules") - val tradeMode = marketplace?.str("tradeMode") ?: "NotTradeable" + val tradeMode = marketplace?.tradeMode("tradeMode") ?: "NotTradeable" val tradeModeChange = marketplace?.rule("tradeModeChangeRules") // History-keeping: nested object or a single boolean for all. @@ -208,6 +208,17 @@ object TokenMaterializer { private fun JsonObject.rule(key: String): ChangeControlRules? = (this[key] as? JsonObject)?.let(ChangeControlRules::parse) + /** + * rs-dpp writes `TokenTradeMode` externally tagged: `NotTradeable` as a + * bare string, and `Tradeable` (protocol version 15) as an object holding + * its fields, `{"Tradeable":{"minFill":1000}}`. The variant name, the + * string or the object's single key, is the mode the column keeps; + * `minFill` is not persisted. Port of `parseTokenTradeMode` in + * `DataContractParser.swift`. + */ + private fun JsonObject.tradeMode(key: String): String? = + str(key) ?: obj(key)?.keys?.singleOrNull() + /** * u64-safe supply extraction — port of `extractTokenSupply` * (numbers or strings; decimal string out). diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt new file mode 100644 index 00000000000..f83dbc7a8ca --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt @@ -0,0 +1,55 @@ +package org.dashfoundation.example.services.tokens + +import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity +import org.dashfoundation.dashsdk.persistence.entities.TokenEntity +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [TokenMaterializer] coverage for `marketplaceRules.tradeMode`. rs-dpp + * writes `TokenTradeMode` externally tagged: `NotTradeable` as a bare + * string, `Tradeable` (protocol version 15) as an object holding its + * `minFill` (a JSON number, or a decimal string above 2^53 - 1). The + * variant name lands in [TokenEntity.tradeMode]; `minFill` is not kept. + * Mirrors `DataContractParserTradeModeTests.swift`. + */ +class TokenMaterializerTradeModeTest { + + private val contractId = ByteArray(32) { 0xAB.toByte() } + + /** [tradeMode] is the raw JSON value of `marketplaceRules.tradeMode`. */ + private fun parseSingleToken(tradeMode: String): TokenEntity { + val json = """{"tokens":{"0":{"baseSupply":0,"marketplaceRules":""" + + """{"${'$'}formatVersion":"0","tradeMode":$tradeMode}}}}""" + val contract = DataContractEntity( + id = contractId, + name = "Fixture", + serializedContract = json.encodeToByteArray(), + networkRaw = 0, + ) + val tokens = TokenMaterializer.parse(contract) + assertEquals(1, tokens.size) + return tokens.single() + } + + @Test + fun `tradeable object form is stored as Tradeable`() { + val token = parseSingleToken("""{"Tradeable":{"minFill":1000}}""") + + assertEquals("Tradeable", token.tradeMode) + } + + @Test + fun `tradeable with a string minFill is stored as Tradeable`() { + val token = parseSingleToken("""{"Tradeable":{"minFill":"9223372036854775807"}}""") + + assertEquals("Tradeable", token.tradeMode) + } + + @Test + fun `bare NotTradeable string stays NotTradeable`() { + val token = parseSingleToken("\"NotTradeable\"") + + assertEquals("NotTradeable", token.tradeMode) + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt index 8ba49efbb96..903acf9dc87 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt @@ -92,7 +92,12 @@ data class TokenEntity( val mintingAllowChoosingDestination: Boolean = true, /** JSON `TokenDistributionChangeRules` (UI-only). */ val distributionChangeRules: String? = null, - /** `TokenTradeMode.rawValue` ("NotTradeable"). */ + /** + * `TokenTradeMode.rawValue`: "NotTradeable" or "Tradeable" (protocol + * version 15). Contract JSON carries `Tradeable` as an object holding its + * `minFill` (`{"Tradeable":{"minFill":1000}}`); only the variant name is + * kept, `minFill` is not persisted. + */ val tradeMode: String = "NotTradeable", val tradeModeChangeRules: String? = null, val mainControlGroupPosition: Int? = null, 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 f022ecc2f2c..669d115597e 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,8 +1,10 @@ +use crate::balances::credits::TokenAmount; use crate::consensus::basic::data_contract::{ NonTransferableTokenShieldedPoolError, NonTransferableTokenTradeableError, TokenShieldedPoolIncompatibleRulesError, }; use crate::consensus::basic::state_transition::StateTransitionNotActiveError; +use crate::consensus::basic::token::InvalidTokenOrderFillRangeError; 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; @@ -82,6 +84,12 @@ impl TokenConfiguration { .is_tradeable() } + /// The smallest fill of the token's orders (`marketplaceRules.tradeMode` `Tradeable`'s + /// `minFill`), `None` when the token is not tradeable. + pub fn min_fill(&self) -> Option { + self.as_cow_v0().marketplace_rules.trade_mode().min_fill() + } + /// Checks the configuration's format version against the bounds the platform version /// admits (`dpp.contract_versions.token_versions.token_configuration_format`). /// @@ -159,7 +167,8 @@ 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. + /// the token from one identity to another, and its minimum fill is from 1 to `i64::MAX` + /// (`InvalidTokenOrderFillRangeError`). Checked on contract create and update. pub fn validate_marketplace_rules( &self, token_contract_position: TokenContractPosition, @@ -200,6 +209,16 @@ impl TokenConfiguration { NonTransferableTokenTradeableError::new(token_contract_position).into(), ); } + // An order stores its amounts as signed 8-byte values, so no order is larger than + // `i64::MAX`, and a minimum fill of 0 would let a fill take nothing + let largest_order = i64::MAX as TokenAmount; + if let Some(min_fill) = trade_mode.min_fill() { + if min_fill == 0 || min_fill > largest_order { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(min_fill, largest_order).into(), + ); + } + } SimpleConsensusValidationResult::new() } @@ -1221,7 +1240,7 @@ mod marketplace_rules_tests { let mut configuration = TokenConfigurationV0::default_most_restrictive(); configuration .marketplace_rules - .set_trade_mode(TokenTradeMode::Tradeable); + .set_trade_mode(TokenTradeMode::Tradeable { min_fill: 1 }); TokenConfiguration::V0(configuration) } @@ -1290,7 +1309,7 @@ mod marketplace_rules_tests { configuration.set_transferable(false); let proposed = configuration.validate_trade_mode( - &TokenTradeMode::Tradeable, + &TokenTradeMode::Tradeable { min_fill: 1 }, 0, PlatformVersion::latest(), ); @@ -1302,12 +1321,44 @@ mod marketplace_rules_tests { )); configuration.apply_token_configuration_item( - TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable { + min_fill: 1, + }), ); let applied = configuration.validate_marketplace_rules(0, PlatformVersion::latest()); assert_eq!(applied.errors, proposed.errors); } + /// A tradeable token's minimum fill is from 1 to the largest order, `i64::MAX`. + #[test] + fn should_refuse_a_minimum_fill_of_0_or_above_the_largest_order() { + let largest_order = i64::MAX as TokenAmount; + for (min_fill, valid) in [ + (0, false), + (1, true), + (largest_order, true), + (largest_order + 1, false), + ] { + let result = tradeable().validate_trade_mode( + &TokenTradeMode::Tradeable { min_fill }, + 0, + PlatformVersion::latest(), + ); + if valid { + assert!(result.is_valid(), "{min_fill}"); + } else { + assert!( + matches!( + result.errors.as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))] + if error.min() == min_fill && error.max() == largest_order + ), + "{min_fill}: {result:?}" + ); + } + } + } + /// Proposing the mode every token may have is never refused. #[test] fn should_accept_proposing_not_tradeable_for_a_non_transferable_token() { 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 b654adda583..fc1be75e373 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 @@ -120,28 +120,35 @@ mod json_convertible_tests { } #[test] - fn should_serialize_tradeable_as_a_bare_string_on_both_wire_formats() { + fn should_serialize_tradeable_with_its_minimum_fill_on_both_wire_formats() { use crate::serialization::{JsonConvertible, ValueConvertible}; - let mut original = fixture(); - let TokenMarketplaceRules::V0(v0) = &mut original; - v0.trade_mode = TokenTradeMode::Tradeable; + for min_fill in [1000, u64::MAX] { + let mut original = fixture(); + let TokenMarketplaceRules::V0(v0) = &mut original; + v0.trade_mode = TokenTradeMode::Tradeable { min_fill }; - 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 json = original.to_json().expect("to_json"); + // Past JavaScript's safe integers, JSON carries it as a string + let written = if min_fill > 9_007_199_254_740_991 { + json!(min_fill.to_string()) + } else { + json!(min_fill) + }; + assert_eq!( + json["tradeMode"], + json!({ "Tradeable": { "minFill": written } }) + ); + 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 - ); + let value = original.to_object().expect("to_object"); + assert_eq!( + TokenMarketplaceRules::from_object(value).expect("from_object"), + original + ); + } } /// `TokenTradeMode` is bincode-encoded positionally inside every stored token configuration @@ -154,6 +161,9 @@ mod json_convertible_tests { .expect("expected to encode the trade mode") }; assert_eq!(tag_of(TokenTradeMode::NotTradeable), vec![0]); - assert_eq!(tag_of(TokenTradeMode::Tradeable), vec![1]); + assert_eq!( + tag_of(TokenTradeMode::Tradeable { min_fill: 5 }), + vec![1, 5] + ); } } 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 370f9981d68..0d1b4012fc7 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 @@ -1,5 +1,6 @@ mod accessors; +use crate::balances::credits::TokenAmount; use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use crate::data_contract::change_control_rules::v0::ChangeControlRulesV0; use crate::data_contract::change_control_rules::ChangeControlRules; @@ -31,7 +32,19 @@ pub enum TokenTradeMode { /// 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, + Tradeable { + /// The smallest amount, in base units, a fill of a listing or an offer of the token + /// takes unless it takes everything the order still has open, and the least a fill + /// leaves open: so no order is left holding less than a trade. An order of less is + /// refused. Every fill reads it as the token has it when the fill executes, so a + /// change applies to the open orders too. From 1 to `i64::MAX`. + #[cfg_attr( + feature = "json-conversion", + serde(with = "crate::serialization::json_safe_u64") + )] + #[serde(rename = "minFill")] + min_fill: TokenAmount, + }, } impl TokenTradeMode { @@ -39,14 +52,23 @@ impl TokenTradeMode { pub fn is_tradeable(&self) -> bool { match self { TokenTradeMode::NotTradeable => false, - TokenTradeMode::Tradeable => true, + TokenTradeMode::Tradeable { .. } => true, + } + } + + /// The smallest fill of the token's orders, `None` when the token is not tradeable. + pub fn min_fill(&self) -> Option { + match self { + TokenTradeMode::NotTradeable => None, + TokenTradeMode::Tradeable { min_fill } => Some(*min_fill), } } } // Manual impls because TokenTradeMode is a flat enum (not versioned V0/V1). -// Unit-only enum — serde default emits the bare "NotTradeable" string on both -// wire formats (pinned by TokenMarketplaceRules' wire-shape tests). +// serde emits the bare "NotTradeable" string on both wire formats, and +// `{ "Tradeable": { "minFill": .. } }` for a tradeable token (pinned by +// TokenMarketplaceRules' wire-shape tests). #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] impl crate::serialization::JsonConvertible for TokenTradeMode {} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs index 3fabba4e0fd..964bbf647dd 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs @@ -8,8 +8,9 @@ use platform_serialization_derive::{ }; use thiserror::Error; -/// A token order's fill range is empty: a listing or an offer whose minimum fill is 0 or above -/// its amount, or a fill whose minimum is 0 or above its maximum. +/// A token order's fill range is empty: a listing or an offer of less than its token's minimum +/// fill (`min` the minimum fill, `max` the order's amount), a tradeable token whose minimum fill is +/// 0 or above `i64::MAX` (`max`), or a fill whose minimum is 0 or above its maximum. #[derive( Error, Debug, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs index 91216d629b6..0cd46ddfcbf 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs @@ -30,7 +30,7 @@ pub mod token_fill_ask_transition; pub mod token_freeze_transition; pub mod token_mint_to_pool_transition; pub mod token_mint_transition; -mod token_order_terms; +pub mod token_order_terms; pub mod token_place_ask_transition; pub mod token_set_price_for_direct_purchase_transition; pub mod token_shield_transition; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs index b914a60e0aa..5a298957a71 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs @@ -16,15 +16,12 @@ use crate::validation::SimpleConsensusValidationResult; /// 8-byte value, and its value is computed from the amount and the price in u128. pub(crate) const MAX_TOKEN_ORDER_TERM: u64 = i64::MAX as u64; -/// The amount and the price are between 1 and `i64::MAX`, the minimum fill between 1 and the -/// amount, and what the order is worth at the decimals it signs (`TokenOrder::order_value`) at -/// most `i64::MAX` credits. Whether the signed decimals are the token's is checked with the -/// batch's advanced structure, which knows the token. +/// The amount and the price are between 1 and `i64::MAX`. What the order needs of the token, its +/// minimum fill and its decimals, is checked with the batch's advanced structure, which knows the +/// token ([`validate_token_order_terms_for_token`]). pub(crate) fn validate_token_order_terms( amount: TokenAmount, price: Credits, - decimals: u8, - min_fill: TokenAmount, ) -> SimpleConsensusValidationResult { if amount == 0 || amount > MAX_TOKEN_ORDER_TERM { return SimpleConsensusValidationResult::new_with_error(ConsensusError::BasicError( @@ -39,7 +36,21 @@ pub(crate) fn validate_token_order_terms( InvalidTokenOrderPriceError::new(price, MAX_TOKEN_ORDER_TERM).into(), ); } - if min_fill == 0 || min_fill > amount { + SimpleConsensusValidationResult::default() +} + +/// What an order of `amount` at `price` needs of its token, given the token's minimum fill and +/// decimals as the token has them when the order is placed: an amount of at least the minimum +/// fill (`InvalidTokenOrderFillRangeError`, the minimum and the amount), so no order opens +/// holding less than a trade, and a worth at the token's decimals (`TokenOrder::order_value`) of +/// at most `i64::MAX` credits (`InvalidTokenOrderValueError`). +pub fn validate_token_order_terms_for_token( + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + decimals: u8, +) -> SimpleConsensusValidationResult { + if amount < min_fill { return SimpleConsensusValidationResult::new_with_error( InvalidTokenOrderFillRangeError::new(min_fill, amount).into(), ); @@ -56,16 +67,19 @@ pub(crate) fn validate_token_order_terms( mod tests { use super::*; - /// An order is worth its amount at its price at the decimals it signs, at most `i64::MAX` + /// An order is worth its amount at its price at the token's decimals, at most `i64::MAX` /// credits: 1 base unit at `i64::MAX` credits per whole token is worth exactly that at 0 - /// decimals, 2 are worth more; at 8 decimals 2 are worth far less. + /// decimals, 2 are worth more; at 8 decimals 2 are worth far less, and from 39 decimals + /// any order is worth one credit. #[test] fn should_refuse_an_order_worth_more_than_an_order_holds() { let at = |amount, decimals| { - validate_token_order_terms(amount, MAX_TOKEN_ORDER_TERM, decimals, 1).errors + validate_token_order_terms_for_token(amount, MAX_TOKEN_ORDER_TERM, 1, decimals).errors }; assert!(at(1, 0).is_empty()); assert!(at(2, 8).is_empty()); + assert!(at(MAX_TOKEN_ORDER_TERM, 39).is_empty()); + assert!(at(MAX_TOKEN_ORDER_TERM, 255).is_empty()); assert!(matches!( at(2, 0).as_slice(), [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] @@ -74,4 +88,18 @@ mod tests { && error.decimals() == 0 )); } + + /// An order of less than the token's minimum fill is refused; one of exactly it, or more, + /// is not. + #[test] + fn should_refuse_an_order_of_less_than_the_minimum_fill() { + let at = |amount| validate_token_order_terms_for_token(amount, 1, 100, 2).errors; + assert!(at(100).is_empty()); + assert!(at(1_000).is_empty()); + assert!(matches!( + at(99).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))] + if error.min() == 100 && error.max() == 99 + )); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs index b219b7919c4..5a9d4c780f4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs @@ -28,24 +28,19 @@ pub struct TokenPlaceAskTransitionV0 { pub base: TokenBaseTransition, /// The token amount listed, in base units. pub amount: TokenAmount, - /// The credits asked per whole token (10 to the power of the token's decimals base units). + /// The credits asked per whole token: 10 to the power of the token's decimals base units, as + /// the token has them when the listing is placed. The listing records them and its fills + /// are priced at them; the token's minimum fill (`TokenTradeMode::Tradeable`) bounds how + /// little a fill takes. pub price: Credits, - /// The token's decimals the price is at. A listing executes only while they are the token's: - /// the price is per whole token, so a change of the token's decimals between signing and - /// execution would otherwise reprice it. - pub decimals: u8, - /// 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. Equal to `amount` for a listing - /// that is sold all at once or not at all. - pub min_fill: TokenAmount, } impl fmt::Display for TokenPlaceAskTransitionV0 { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "Token PlaceAsk, base: {}, amount: {}, price: {} at {} decimals, min fill: {}", - self.base, self.amount, self.price, self.decimals, self.min_fill + "Token PlaceAsk, base: {}, amount: {}, price: {}", + self.base, self.amount, self.price ) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs index 3d32a9a5894..ddc4deba9ce 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs @@ -26,15 +26,6 @@ pub trait TokenPlaceAskTransitionV0Methods: TokenBaseTransitionAccessors { fn price(&self) -> Credits; fn set_price(&mut self, price: Credits); - - /// The token's decimals the price is at - fn decimals(&self) -> u8; - - fn set_decimals(&mut self, decimals: u8); - - fn min_fill(&self) -> TokenAmount; - - fn set_min_fill(&mut self, min_fill: TokenAmount); } impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { @@ -53,20 +44,4 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { fn set_price(&mut self, price: Credits) { self.price = price; } - - fn decimals(&self) -> u8 { - self.decimals - } - - fn set_decimals(&mut self, decimals: u8) { - self.decimals = decimals; - } - - fn min_fill(&self) -> TokenAmount { - self.min_fill - } - - fn set_min_fill(&mut self, min_fill: TokenAmount) { - self.min_fill = min_fill; - } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs index 59c1ef0b1bb..5ba2c38ef51 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs @@ -49,28 +49,4 @@ impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransition { TokenPlaceAskTransition::V0(v0) => v0.set_price(price), } } - - fn decimals(&self) -> u8 { - match self { - TokenPlaceAskTransition::V0(v0) => v0.decimals(), - } - } - - fn set_decimals(&mut self, decimals: u8) { - match self { - TokenPlaceAskTransition::V0(v0) => v0.set_decimals(decimals), - } - } - - fn min_fill(&self) -> TokenAmount { - match self { - TokenPlaceAskTransition::V0(v0) => v0.min_fill(), - } - } - - fn set_min_fill(&mut self, min_fill: TokenAmount) { - match self { - TokenPlaceAskTransition::V0(v0) => v0.set_min_fill(min_fill), - } - } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs index 6abf355f3a1..0eb3442000e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -49,37 +49,17 @@ mod tests { base: Default::default(), amount, price, - decimals: 8, - min_fill: 1, }) .validate_structure(PlatformVersion::latest()) .expect("expected the structure validation to run") .errors } - /// A listing of a token with 39 or more decimals is worth one credit at most, so terms at - /// their bounds pass the structure validation there too. - #[test] - fn should_accept_a_listing_of_a_token_with_39_or_more_decimals() { - for decimals in [39, 255] { - let errors = TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { - base: Default::default(), - amount: SIGNED_MAX, - price: SIGNED_MAX, - decimals, - min_fill: 1, - }) - .validate_structure(PlatformVersion::latest()) - .expect("expected the structure validation to run") - .errors; - assert!(errors.is_empty(), "decimals {decimals}: {errors:?}"); - } - } - /// A listing's amount and price are stored as signed 8-byte values, so each is 1 to /// `i64::MAX`. At 8 decimals, `i64::MAX + 1` base units at 1 credit and 1 base unit at /// `i64::MAX + 1` credits are both worth what an order holds, so only these bounds refuse - /// them before they reach the order's document. + /// them before they reach the order's document. What the listing is worth at the token's + /// decimals is checked with the batch's advanced structure, which knows the token. #[test] fn should_refuse_an_amount_or_price_outside_a_signed_8_byte_value() { for (term, accepted) in [ diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs index 48e9a9d978a..9ec7d92328b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -11,11 +11,6 @@ pub(super) trait TokenPlaceAskTransitionStructureValidationV0 { impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { /// The listing's terms, checked as an offer's are: see `validate_token_order_terms`. fn validate_structure_v0(&self) -> Result { - Ok(validate_token_order_terms( - self.amount(), - self.price(), - self.decimals(), - self.min_fill(), - )) + Ok(validate_token_order_terms(self.amount(), self.price())) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 733873ad92d..99b37a29960 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -1583,8 +1583,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { token_contract_position: u16, amount: TokenAmount, price: Credits, - decimals: u8, - min_fill: TokenAmount, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, @@ -1615,8 +1613,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { token_contract_position, amount, price, - decimals, - min_fill, identity_public_key, identity_contract_nonce, user_fee_increase, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs index 5acacbd4be9..fe831846e06 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs @@ -598,8 +598,6 @@ pub trait DocumentsBatchTransitionMethodsV1: DocumentsBatchTransitionAccessorsV0 token_contract_position: u16, amount: TokenAmount, price: Credits, - decimals: u8, - min_fill: TokenAmount, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index bf30104b494..4e1cd745d29 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -1503,8 +1503,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { token_contract_position: u16, amount: TokenAmount, price: Credits, - decimals: u8, - min_fill: TokenAmount, identity_public_key: &IdentityPublicKey, identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, @@ -1522,8 +1520,6 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { }), amount, price, - decimals, - min_fill, }); let batch_transition: BatchTransition = BatchTransitionV1 { diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs index 7da1cde3fe4..65a965c9337 100644 --- a/packages/rs-dpp/src/tokens/token_order.rs +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -14,7 +14,6 @@ pub mod property_names { 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"; } @@ -46,13 +45,12 @@ pub struct TokenOrder { pub amount: TokenAmount, /// The amount still open. pub remaining: TokenAmount, - /// The smallest amount a fill takes unless it takes everything open; a fill never leaves - /// less than this open either. - pub min_fill: TokenAmount, /// For an offer, the credits still held in escrow; 0 for a listing, whose escrow holds /// tokens (`remaining`). pub escrow: Credits, - /// The token's decimals when the order was placed, the scale of its price. + /// The token's decimals when the order was placed, the scale of its price: what the token + /// market contract's `generatedFrom` (`sys.tokens.decimals` of `tokenId`) generates, and + /// keeps for the life of the order. pub decimals: u8, } @@ -84,24 +82,27 @@ impl TokenOrder { } /// The amount a fill asking for between `min_amount` and `max_amount` takes, or `None` when - /// no amount in that range keeps the order's rules. + /// no amount in that range keeps the token's rules. `min_fill` is the token's minimum fill + /// (`TokenTradeMode::Tradeable`) when the fill executes. /// /// The fill takes as much as it may: everything open, up to `max_amount`. When that would /// leave less than `min_fill` open, it takes less, leaving exactly `min_fill`, if that still /// takes at least `min_fill` and `min_amount`. A fill that does not take everything open takes - /// at least `min_fill`. + /// at least `min_fill`; one that takes everything open may take less, so an order left under + /// a minimum the token raised since is still filled whole. pub fn fill_amount( &self, min_amount: TokenAmount, max_amount: TokenAmount, + min_fill: TokenAmount, ) -> Option { let mut take = max_amount.min(self.remaining); if take < self.remaining { let left = self.remaining - take; - if left < self.min_fill { - take = self.remaining.checked_sub(self.min_fill)?; + if left < min_fill { + take = self.remaining.checked_sub(min_fill)?; } - if take < self.min_fill { + if take < min_fill { return None; } } @@ -181,7 +182,6 @@ impl TokenOrder { price: integer(property_names::PRICE)?, amount: integer(property_names::AMOUNT)?, remaining: integer(property_names::REMAINING)?, - min_fill: integer(property_names::MIN_FILL)?, escrow, decimals, }) @@ -206,7 +206,6 @@ impl TokenOrder { property_names::REMAINING.to_string(), signed(self.remaining)?, ), - (property_names::MIN_FILL.to_string(), signed(self.min_fill)?), ( property_names::DECIMALS.to_string(), Value::I64(self.decimals as i64), @@ -263,12 +262,7 @@ impl TokenOrder { mod tests { use super::*; - fn order( - side: TokenOrderSide, - amount: TokenAmount, - remaining: TokenAmount, - min_fill: TokenAmount, - ) -> TokenOrder { + fn order(side: TokenOrderSide, amount: TokenAmount, remaining: TokenAmount) -> TokenOrder { TokenOrder { id: Identifier::from([1; 32]), side, @@ -277,7 +271,6 @@ mod tests { price: 333, amount, remaining, - min_fill, escrow: 0, decimals: 2, } @@ -285,37 +278,37 @@ mod tests { #[test] fn should_take_everything_open_up_to_the_fill_maximum() { - let listing = order(TokenOrderSide::Ask, 1000, 1000, 1); - assert_eq!(listing.fill_amount(1, 400), Some(400)); - assert_eq!(listing.fill_amount(1, 5000), Some(1000)); - assert_eq!(listing.fill_amount(1001, 5000), None); + let listing = order(TokenOrderSide::Ask, 1000, 1000); + assert_eq!(listing.fill_amount(1, 400, 1), Some(400)); + assert_eq!(listing.fill_amount(1, 5000, 1), Some(1000)); + assert_eq!(listing.fill_amount(1001, 5000, 1), None); } #[test] fn should_leave_at_least_the_minimum_fill_open() { - let listing = order(TokenOrderSide::Ask, 1000, 1000, 100); + let listing = order(TokenOrderSide::Ask, 1000, 1000); // 950 would leave 50: the fill takes 900 and leaves 100. - assert_eq!(listing.fill_amount(1, 950), Some(900)); + assert_eq!(listing.fill_amount(1, 950, 100), Some(900)); // Unless the fill asked for more than that. - assert_eq!(listing.fill_amount(901, 950), None); + assert_eq!(listing.fill_amount(901, 950, 100), None); // A fill under the minimum fill that does not take everything is refused. - assert_eq!(listing.fill_amount(1, 50), None); - // Everything open is always takeable. - let tail = order(TokenOrderSide::Ask, 1000, 60, 100); - assert_eq!(tail.fill_amount(1, 1000), Some(60)); + assert_eq!(listing.fill_amount(1, 50, 100), None); + // Everything open is always takeable, even under a minimum the token raised since. + let tail = order(TokenOrderSide::Ask, 1000, 60); + assert_eq!(tail.fill_amount(1, 1000, 100), Some(60)); } #[test] - fn should_sell_an_all_or_none_listing_only_whole() { - let listing = order(TokenOrderSide::Ask, 1000, 1000, 1000); - assert_eq!(listing.fill_amount(1, 999), None); - assert_eq!(listing.fill_amount(1, 1000), Some(1000)); + fn should_sell_a_listing_under_the_minimum_fill_only_whole() { + let listing = order(TokenOrderSide::Ask, 1000, 1000); + assert_eq!(listing.fill_amount(1, 999, 1000), None); + assert_eq!(listing.fill_amount(1, 1000, 1000), Some(1000)); } /// 2 decimals at 333 credits per token is 3.33 credits per base unit. #[test] fn should_charge_the_running_total_so_splitting_never_changes_the_cost() { - let mut listing = order(TokenOrderSide::Ask, 3, 3, 1); + let mut listing = order(TokenOrderSide::Ask, 3, 3); let mut paid = vec![]; for _ in 0..3 { let credits = listing.fill_credits(1).expect("expected the cost"); @@ -323,7 +316,7 @@ mod tests { listing.remaining -= 1; } assert_eq!(paid, vec![4, 3, 3]); - let whole = order(TokenOrderSide::Ask, 3, 3, 1); + let whole = order(TokenOrderSide::Ask, 3, 3); assert_eq!(whole.fill_credits(3).expect("expected the cost"), 10); } @@ -331,7 +324,7 @@ mod tests { fn should_round_an_offers_payouts_down_and_keep_them_within_its_escrow() { let escrow = TokenOrder::order_value(1001, 333, 2).expect("expected a value"); assert_eq!(escrow, 3334); - let mut offer = order(TokenOrderSide::Bid, 1001, 1001, 1); + let mut offer = order(TokenOrderSide::Bid, 1001, 1001); let mut paid = 0; while offer.remaining > 0 { let take = offer.remaining.min(7); @@ -346,7 +339,7 @@ mod tests { /// same price and amount cost 100 times more at 0 decimals than at 2. #[test] fn should_price_fills_at_the_orders_own_decimals() { - let mut listing = order(TokenOrderSide::Ask, 100, 100, 1); + let mut listing = order(TokenOrderSide::Ask, 100, 100); listing.price = 100; assert_eq!(listing.fill_credits(10).expect("expected the cost"), 10); listing.decimals = 0; @@ -373,14 +366,14 @@ mod tests { assert_eq!(TokenOrder::order_value(1, 1, decimals), Some(1)); assert_eq!(TokenOrder::order_value(max, max, decimals), Some(1)); - let mut listing = order(TokenOrderSide::Ask, 1_000, 1_000, 1); + let mut listing = order(TokenOrderSide::Ask, 1_000, 1_000); listing.decimals = decimals; listing.price = max; assert_eq!(listing.fill_credits(400).ok(), Some(1)); listing.remaining = 600; assert_eq!(listing.fill_credits(600).ok(), Some(0)); - let mut offer = order(TokenOrderSide::Bid, 1_000, 1_000, 1); + let mut offer = order(TokenOrderSide::Bid, 1_000, 1_000); offer.decimals = decimals; offer.price = max; assert_eq!(offer.fill_credits(1_000).ok(), Some(0)); @@ -394,7 +387,7 @@ mod tests { #[test] fn should_round_trip_through_its_document() { - let mut offer = order(TokenOrderSide::Bid, 1000, 400, 10); + let mut offer = order(TokenOrderSide::Bid, 1000, 400); offer.escrow = 1998; let block_info = BlockInfo::default(); let document = offer 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 ca9f49402a5..f62b379cf0b 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 @@ -855,7 +855,7 @@ mod tests { #[test] fn should_refuse_making_a_token_tradeable_before_protocol_version_15() { - let result = trade_mode_update(TokenTradeMode::Tradeable) + let result = trade_mode_update(TokenTradeMode::Tradeable { min_fill: 1 }) .validate_is_allowed(protocol_version_14()) .expect("should not error"); assert_matches!( @@ -882,7 +882,7 @@ mod tests { 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) + let result = trade_mode_update(TokenTradeMode::Tradeable { min_fill: 1 }) .validate_is_allowed(platform_version) .expect("should not error"); assert!(result.is_valid(), "{:?}", result.errors); @@ -894,11 +894,12 @@ mod tests { #[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::Tradeable { min_fill: 1 }, + platform_version + ) + .has_is_allowed_validation() + .expect("should not error")); assert!(!contract_create_with_trade_mode( TokenTradeMode::NotTradeable, platform_version @@ -910,10 +911,12 @@ mod tests { #[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"); + let result = contract_create_with_trade_mode( + TokenTradeMode::Tradeable { min_fill: 1 }, + platform_version, + ) + .validate_is_allowed(platform_version) + .expect("should not error"); assert_matches!( result.errors.as_slice(), [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] @@ -927,10 +930,12 @@ mod tests { 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"); + let result = contract_create_with_trade_mode( + TokenTradeMode::Tradeable { min_fill: 1 }, + 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_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs index e98687e7623..842c744bad0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -1,7 +1,8 @@ use dpp::block::block_info::BlockInfo; -use dpp::consensus::state::token::TokenOrderDecimalsMismatchError; +use dpp::consensus::state::token::TokenNotTradeableError; 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::state_transition::batch_transition::batched_transition::token_order_terms::validate_token_order_terms_for_token; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; @@ -21,10 +22,9 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch) impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransitionAction { /// What a listing needs that reads nothing beyond the contract the action carries: a - /// tradeable token, and the decimals it was signed at being the token's (its price is per - /// whole token, so a change of the token's decimals between signing and execution would - /// reprice it). What the listing is worth at the decimals it signs is checked with the - /// transition's structure. + /// tradeable token, an amount of at least the token's minimum fill, and a worth of at most + /// `i64::MAX` credits at the token's decimals (`validate_token_order_terms_for_token`), both + /// as the token has them now. fn validate_structure_v0( &self, _block_info: &BlockInfo, @@ -35,19 +35,17 @@ impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransit return Ok(validation_result); } - let token_decimals = self.base().token_configuration()?.conventions().decimals(); - let decimals = self.decimals(); - if decimals != token_decimals { + let token_configuration = self.base().token_configuration()?; + let Some(min_fill) = token_configuration.min_fill() else { return Ok(SimpleConsensusValidationResult::new_with_error( - TokenOrderDecimalsMismatchError::new( - self.base().token_id(), - decimals, - token_decimals, - ) - .into(), + TokenNotTradeableError::new(self.base().token_id()).into(), )); - } - - Ok(SimpleConsensusValidationResult::new()) + }; + Ok(validate_token_order_terms_for_token( + self.amount(), + self.price(), + min_fill, + token_configuration.conventions().decimals(), + )) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 33fba4d9b10..c8c631d4a77 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -56,10 +56,25 @@ const PRICE: Credits = 1_000_000_000; const BASE_SUPPLY: u64 = 100_000; fn make_token_tradeable(token_configuration: &mut TokenConfiguration) { + make_token_tradeable_with_min_fill(token_configuration, 1); +} + +/// The token is tradeable, its orders filled `min_fill` base units at least. +fn make_token_tradeable_with_min_fill(token_configuration: &mut TokenConfiguration, min_fill: u64) { token_configuration .as_v0_mut() .marketplace_rules - .set_trade_mode(TokenTradeMode::Tradeable); + .set_trade_mode(TokenTradeMode::Tradeable { min_fill }); +} + +/// A tradeable market whose token's orders are filled 100 base units at least. +fn market_with_min_fill_100() -> Market { + market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable_with_min_fill(token_configuration, 100) + }, + dash_to_credits!(1.0), + ) } fn make_token_tradeable_and_pausable(token_configuration: &mut TokenConfiguration) { @@ -281,14 +296,8 @@ impl Market { } /// A listing by the seller, and its order id. - async fn place_ask( - &mut self, - amount: u64, - price: Credits, - min_fill: u64, - ) -> (StateTransition, Identifier) { + async fn place_ask(&mut self, amount: u64, price: Credits) -> (StateTransition, Identifier) { let platform_version = PlatformVersion::latest(); - let decimals = self.token_decimals(); let nonce = self.seller.take_nonce(); let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.seller.id(), nonce); let transition = BatchTransition::new_token_place_ask_transition( @@ -298,8 +307,6 @@ impl Market { 0, amount, price, - decimals, - min_fill, &self.seller.key, nonce, 0, @@ -467,12 +474,11 @@ impl Market { self.assert_executes(&freeze, &BlockInfo::default()); } - /// A listing by the buyer at `decimals`, and its order id. + /// A listing by the buyer, and its order id. async fn buyer_places_ask( &mut self, amount: u64, price: Credits, - decimals: u8, ) -> (StateTransition, Identifier) { let platform_version = PlatformVersion::latest(); let nonce = self.buyer.take_nonce(); @@ -484,8 +490,6 @@ impl Market { 0, amount, price, - decimals, - 1, &self.buyer.key, nonce, 0, @@ -522,26 +526,6 @@ impl Market { .expect("expected to build the fill") } - /// The token's decimals as stored now, which a placement signs. - fn token_decimals(&self) -> u8 { - let platform_version = PlatformVersion::latest(); - self.platform - .drive - .get_contract_with_fetch_info( - self.contract.id().to_buffer(), - false, - None, - platform_version, - ) - .expect("expected to fetch the token's contract") - .expect("expected the token's contract") - .contract - .expected_token_configuration(0) - .expect("expected the token") - .conventions() - .decimals() - } - async fn set_decimals(&mut self, decimals: u8) { let platform_version = PlatformVersion::latest(); let mut conventions = self @@ -573,6 +557,33 @@ impl Market { self.assert_executes(&transition, &BlockInfo::default()); } + /// Sets the token's minimum fill to `min_fill`, as the seller, through a configuration + /// update of its trade mode. + async fn set_min_fill(&mut self, min_fill: u64) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_config_update_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable { + min_fill, + }), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the trade mode update"); + self.assert_executes(&transition, &BlockInfo::default()); + } + /// A listing by the seller of the token at `position` of the contract `contract_id`, with /// the seller's identity contract nonce `nonce` for that contract. async fn place_ask_naming( @@ -589,8 +600,6 @@ impl Market { position, 1_000, PRICE, - 8, - 1, &self.seller.key, nonce, 0, @@ -796,8 +805,8 @@ impl Market { #[tokio::test] async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { - let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( @@ -816,7 +825,6 @@ async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { price: PRICE, amount: 1_000, remaining: 1_000, - min_fill: 100, escrow: 0, decimals: 8, } @@ -878,7 +886,7 @@ fn with_base( #[tokio::test] async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_not_derive() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let mut two_tokens = market.contract.clone(); let token = market @@ -912,7 +920,7 @@ async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_ #[tokio::test] async fn should_not_prove_a_listing_carrying_group_information() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); assert_matches!( @@ -933,7 +941,7 @@ async fn should_not_prove_a_listing_carrying_group_information() { #[tokio::test] async fn should_prove_a_listing_after_it_is_partly_filled() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 400, 4_000).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -962,8 +970,8 @@ async fn should_prove_a_listing_after_it_is_partly_filled() { #[tokio::test] async fn should_sell_part_of_a_listing_and_then_the_rest() { - let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); let buyer_credits = market.credit_balance(market.buyer.id()); @@ -1020,7 +1028,7 @@ async fn should_sell_part_of_a_listing_and_then_the_rest() { #[tokio::test] async fn should_fill_a_listing_twice_in_one_block() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); @@ -1063,7 +1071,7 @@ async fn should_fill_a_listing_twice_in_one_block() { #[tokio::test] async fn should_charge_each_fill_the_change_in_the_running_total() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(7, 150_000_000, 1).await; + let (place, order_id) = market.place_ask(7, 150_000_000).await; market.assert_executes(&place, &BlockInfo::default()); let seller_credits = market.credit_balance(market.seller.id()); @@ -1093,7 +1101,7 @@ async fn should_charge_each_fill_the_change_in_the_running_total() { #[tokio::test] async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(3, 50_000_000, 1).await; + let (place, order_id) = market.place_ask(3, 50_000_000).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 1, 1).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -1139,7 +1147,7 @@ async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { async fn should_refuse_a_listing_amount_above_i64_max() { let mut market = tradeable_market(); let seller_credits = market.credit_balance(market.seller.id()); - let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1, 1).await; + let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1).await; assert_matches!( market.check_tx(&place).as_slice(), [ConsensusError::BasicError( @@ -1174,7 +1182,7 @@ async fn should_charge_the_fill_that_widens_a_listings_revision() { }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(200, PRICE, 1).await; + let (place, order_id) = market.place_ask(200, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let mut fills_adding_storage = vec![]; @@ -1227,7 +1235,7 @@ async fn should_charge_the_fill_that_widens_a_listings_revision() { #[tokio::test] async fn should_return_the_tokens_of_a_cancelled_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 250, 2_500).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -1288,8 +1296,6 @@ async fn should_charge_a_listing_the_hash_of_its_id_where_it_is_derived() { }), amount: 1_000, price: PRICE, - decimals: 8, - min_fill: 1, }; TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( &market.platform.drive, @@ -1356,7 +1362,7 @@ async fn should_charge_a_listing_refused_after_its_id_is_derived_the_hash() { #[tokio::test] async fn should_refuse_listing_a_token_that_is_not_tradeable() { let mut market = market_with(|_| {}, dash_to_credits!(1.0)); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; // The rule reads only the contract the action carries, so the mempool refuses it too. assert_matches!( @@ -1395,40 +1401,78 @@ fn make_token_tradeable_with_owner_conventions_and_freezes( token_configuration.set_freeze_rules(owner_rules); } -/// A listing's price is per whole token, so the decimals set what it is worth: 1,000 base -/// units at 1,000,000,000 credits per whole token are 10,000 credits at 8 decimals and 1 credit -/// at 16. A holder signs a listing at the token's 8 decimals; the identity controlling the -/// token's conventions (the contract owner, not the holder) changes them to 16 before the -/// listing executes. The listing is refused with `TokenOrderDecimalsMismatchError`, by check_tx -/// and in a block, and nothing moves into escrow. +/// A listing of less than the token's minimum fill would open holding less than a trade: it is +/// refused with `InvalidTokenOrderFillRangeError` (the minimum, then the amount), by check_tx +/// and in a block, and nothing moves into escrow. A listing of exactly the minimum is placed. #[tokio::test] -async fn should_refuse_a_listing_signed_at_other_decimals() { - let mut market = market_with( - make_token_tradeable_with_owner_conventions_and_freezes, - dash_to_credits!(1.0), - ); - market.transfer_to_buyer(5_000).await; - let (listing, order_id) = market.buyer_places_ask(1_000, 1_000_000_000, 8).await; - - market.set_decimals(16).await; +async fn should_refuse_a_listing_of_less_than_the_tokens_minimum_fill() { + let mut market = market_with_min_fill_100(); + let (listing, order_id) = market.place_ask(99, PRICE).await; assert_matches!( market.check_tx(&listing).as_slice(), - [ConsensusError::StateError(StateError::TokenOrderDecimalsMismatchError(error))] - if error.signed_decimals() == 8 && error.token_decimals() == 16 + [ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))] + if error.min() == 100 && error.max() == 99 ); assert_matches!( market.process(&listing).execution_results().as_slice(), [StateTransitionExecutionResult::PaidConsensusError { - error: error @ ConsensusError::StateError( - StateError::TokenOrderDecimalsMismatchError(inner) - ), + error: error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(_)), .. - }] if inner.token_id() == market.token_id && error.code() == 40758 + }] if error.code() == 10463 ); - assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); assert_eq!(market.escrow(order_id), None); assert_eq!(market.listing(order_id), None); + + let (listing, order_id) = market.place_ask(100, PRICE).await; + market.assert_executes(&listing, &BlockInfo::default()); + assert_eq!(market.escrow(order_id), Some(100)); +} + +/// The contract owner (the seller) may change the token's trade mode, and with it the minimum +/// fill. +fn make_token_tradeable_with_owner_trade_mode(token_configuration: &mut TokenConfiguration) { + make_token_tradeable(token_configuration); + token_configuration + .as_v0_mut() + .marketplace_rules + .set_trade_mode_change_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + })); +} + +/// Every fill reads the token's minimum fill as the token has it then: a listing of 1,000 +/// placed at a minimum of 1 is, once the minimum is 600, no longer sold 500 at a time (500 +/// would take less than 600, and leave less), but is still sold whole. +#[tokio::test] +async fn should_fill_open_listings_by_the_tokens_current_minimum_fill() { + let mut market = market_with( + make_token_tradeable_with_owner_trade_mode, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + market.set_min_fill(600).await; + + let fill = market.fill_ask(order_id, 1, 500, 1_000_000).await; + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderFillAmountError(error)), + .. + }] if error.min_fill() == 600 + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + + let fill = market.fill_ask(order_id, 1, 1_000, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!(market.listing(order_id), None); } /// A listing whose maker's account is frozen cannot be filled: the fill is refused with the @@ -1441,7 +1485,7 @@ async fn should_refuse_filling_a_frozen_makers_listing_and_let_the_maker_cancel( dash_to_credits!(1.0), ); market.transfer_to_buyer(5_000).await; - let (place, order_id) = market.buyer_places_ask(1_000, PRICE, 8).await; + let (place, order_id) = market.buyer_places_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); market.freeze_the_buyer().await; @@ -1479,7 +1523,7 @@ async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); market.freeze_the_buyer().await; @@ -1511,7 +1555,7 @@ async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to #[tokio::test] async fn should_refuse_listing_more_than_the_seller_holds() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE, 1).await; + let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE).await; let processing_result = market.process(&place); assert_matches!( @@ -1527,9 +1571,9 @@ async fn should_refuse_listing_more_than_the_seller_holds() { assert_eq!(market.listing(order_id), None); } -/// What a listing is worth at the decimals it signs is checked with the transition's structure: -/// two whole tokens at `i64::MAX` credits each are refused by check_tx and in a block before -/// anything is charged, and nothing moves. +/// What a listing is worth at the token's decimals is checked with the batch's advanced +/// structure, which knows the token: two whole tokens at `i64::MAX` credits each are refused by +/// check_tx, and in a block for a fee, and no token moves. #[tokio::test] async fn should_refuse_a_listing_worth_more_than_an_order_holds() { let mut market = market_with( @@ -1540,7 +1584,7 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { dash_to_credits!(1.0), ); let seller_credits = market.credit_balance(market.seller.id()); - let (place, order_id) = market.place_ask(2, i64::MAX as u64, 1).await; + let (place, order_id) = market.place_ask(2, i64::MAX as u64).await; assert_matches!( market.check_tx(&place).as_slice(), [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] @@ -1550,11 +1594,12 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(_)) - )] if error.code() == 10465 + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(_)), + .. + }] if error.code() == 10465 ); - assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert!(market.credit_balance(market.seller.id()) < seller_credits); assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); assert_eq!(market.listing(order_id), None); } @@ -1562,7 +1607,7 @@ async fn should_refuse_a_listing_worth_more_than_an_order_holds() { #[tokio::test] async fn should_refuse_filling_ones_own_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.seller_fill_ask(order_id).await; @@ -1580,7 +1625,7 @@ async fn should_refuse_filling_ones_own_listing() { #[tokio::test] async fn should_refuse_a_fill_above_the_buyers_price_limit() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 1, 400, 3_999).await; @@ -1595,12 +1640,12 @@ async fn should_refuse_a_fill_above_the_buyers_price_limit() { assert_eq!(market.escrow(order_id), Some(1_000)); } -/// A listing of 1,000 with a minimum fill of 100: a fill of 950 would leave 50 open, so the -/// fill takes 900 instead, which a buyer asking for at least 950 does not accept. +/// A listing of 1,000 of a token whose minimum fill is 100: a fill of 950 would leave 50 open, +/// so the fill takes 900 instead, which a buyer asking for at least 950 does not accept. #[tokio::test] async fn should_refuse_a_fill_the_minimum_fill_cannot_meet() { - let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 100).await; + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let fill = market.fill_ask(order_id, 950, 950, 1_000_000).await; @@ -1639,7 +1684,7 @@ async fn should_refuse_filling_a_listing_that_does_not_exist() { #[tokio::test] async fn should_refuse_cancelling_someone_elses_listing() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let cancel = market.cancel(order_id, true).await; @@ -1657,7 +1702,7 @@ async fn should_refuse_cancelling_someone_elses_listing() { #[tokio::test] async fn should_refuse_a_fill_of_a_paused_token_but_still_cancel() { let mut market = market_with(make_token_tradeable_and_pausable, dash_to_credits!(1.0)); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); market.pause().await; @@ -1684,10 +1729,10 @@ async fn should_not_fill_a_listing_the_buyer_cannot_pay_for() { let mut market = market_with(make_token_tradeable, dash_to_credits!(0.0001)); let buyer_credits = market.credit_balance(market.buyer.id()); // 10,000,000 credits for the whole listing: the buyer's balance, short of the fee. - let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000, 1).await; + let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000).await; market.assert_executes(&place, &BlockInfo::default()); // 20,000,000 credits: twice the buyer's balance. - let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000, 1).await; + let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!(buyer_credits, 10_000_000); let buyer_nonce = market.contract_nonce(market.buyer.id()); @@ -1735,7 +1780,7 @@ async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { }, dash_to_credits!(1.0), ); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( market.listing(order_id).map(|listing| listing.decimals), @@ -1765,7 +1810,7 @@ async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { assert_eq!((listing.remaining, listing.decimals), (800, 8)); // A listing placed after the change records the new decimals. - let (place, newer) = market.place_ask(10, PRICE, 1).await; + let (place, newer) = market.place_ask(10, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); assert_eq!( market.listing(newer).map(|listing| listing.decimals), @@ -1821,7 +1866,7 @@ async fn should_refuse_a_listing_of_a_token_the_contract_does_not_define() { #[tokio::test] async fn should_refuse_a_fill_of_a_token_the_contract_does_not_define() { let mut market = tradeable_market(); - let (place, order_id) = market.place_ask(1_000, PRICE, 1).await; + let (place, order_id) = market.place_ask(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); let platform_version = PlatformVersion::latest(); 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 8e1959a5d08..9e953ecf3b5 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 @@ -635,7 +635,9 @@ mod tradeable { identity.id(), contract.id(), 0, - TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable { + min_fill: 1, + }), None, None, &key, 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 5413aa8c88f..8c52ace9f70 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 @@ -544,7 +544,7 @@ async fn should_keep_registering_a_dotted_summable_name_at_protocol_version_13() #[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), + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable { min_fill: 1 }), 13, dash_to_credits!(1.0), ) 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 5f4505a53cd..2826b1d04bf 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 @@ -361,7 +361,7 @@ async fn should_refuse_an_update_dropping_unique_items_from_an_index_only_indexe #[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), + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable { min_fill: 1 }), 13, ) .await; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs index a0ba46d7c2b..cef293c610e 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -134,14 +134,17 @@ impl TokenFillAskTransitionActionV0 { fee_result, ); } - let Some(fill_amount) = order.fill_amount(*min_amount, *max_amount) else { + // The token's minimum fill as the token has it now. A token that is no longer tradeable + // has none, and the batch's advanced structure refuses the fill + let min_fill = base_action.token_configuration()?.min_fill().unwrap_or(1); + let Some(fill_amount) = order.fill_amount(*min_amount, *max_amount, min_fill) else { return bump( vec![TokenOrderFillAmountError::new( *order_id, *min_amount, *max_amount, order.remaining, - order.min_fill, + min_fill, ) .into()], fee_result, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs index 20ef3f2c97a..9d4d12409e2 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs @@ -52,12 +52,6 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionAction } } - fn min_fill(&self) -> TokenAmount { - match self { - TokenPlaceAskTransitionAction::V0(v0) => v0.min_fill, - } - } - fn decimals(&self) -> u8 { match self { TokenPlaceAskTransitionAction::V0(v0) => v0.decimals, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs index 764c6b2ac26..c70d80d1bac 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs @@ -19,10 +19,9 @@ pub struct TokenPlaceAskTransitionActionV0 { pub amount: TokenAmount, /// The credits asked per whole token pub price: Credits, - /// The smallest fill the seller accepts unless it takes everything open - pub min_fill: TokenAmount, - /// The token's decimals the listing was signed at, which its price is at and its order - /// records; the structure validation refuses them unless they are the token's + /// The token's decimals when the listing is placed, which its price is at and its order + /// records (what the token market contract's `generatedFrom`, `sys.tokens.decimals`, + /// generates) pub decimals: u8, /// The listing's `ask` document, stamped with the block that places it pub order_document: Document, @@ -47,10 +46,7 @@ pub trait TokenPlaceAskTransitionActionAccessorsV0 { /// The credits asked per whole token fn price(&self) -> Credits; - /// The smallest fill the seller accepts - fn min_fill(&self) -> TokenAmount; - - /// The token's decimals the listing was signed at + /// The token's decimals when the listing is placed fn decimals(&self) -> u8; /// The listing's `ask` document @@ -96,10 +92,6 @@ impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionActionV self.price } - fn min_fill(&self) -> TokenAmount { - self.min_fill - } - fn decimals(&self) -> u8 { self.decimals } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs index 062eaa0258a..c87c09bd5ca 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use grovedb::TransactionArg; use dpp::block::block_info::BlockInfo; +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::identifier::Identifier; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; @@ -21,11 +23,13 @@ use crate::state_transition_action::batch::BatchedTransitionAction; impl TokenPlaceAskTransitionActionV0 { /// Converts a borrowed `TokenPlaceAskTransitionV0` into an action. The listing's order id - /// follows from the token, the seller and the nonce, and it records the token's decimals, - /// which price its fills; a transition naming a token its contract does not define bumps the - /// nonce with `ContractHasNoTokensError` or `InvalidTokenPositionError`. Everything else the - /// listing needs from state (the token's rules, the seller's balance) is checked by the - /// state validation. + /// follows from the token, the seller and the nonce, and it records the token's decimals as + /// the token has them now, which price its fills (the token market contract declares them + /// `generatedFrom` `sys.tokens.decimals` of the order's token); a transition naming a token + /// its contract does not define bumps the nonce with `ContractHasNoTokensError` or + /// `InvalidTokenPositionError`. The token's rules (tradeable, the minimum fill, what the + /// listing is worth at the decimals) are checked with the batch's advanced structure, and + /// the seller's balance by the state validation. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( drive: &Drive, @@ -48,8 +52,6 @@ impl TokenPlaceAskTransitionActionV0 { base, amount, price, - decimals, - min_fill, } = value; let mut drive_operations = vec![]; @@ -93,9 +95,8 @@ impl TokenPlaceAskTransitionActionV0 { if let Err(error) = base_action.token_configuration_or_position_error() { return bump(vec![error], fee_result); } - // The listing is priced at the decimals it was signed at: the structure validation refuses - // it unless they are the token's. - let decimals = *decimals; + // The listing is priced at the token's decimals as the token has them now, for its life + let decimals = base_action.token_configuration()?.conventions().decimals(); let (order_id, order_id_fee) = derive_order_id( drive, @@ -115,7 +116,6 @@ impl TokenPlaceAskTransitionActionV0 { price: *price, amount: *amount, remaining: *amount, - min_fill: *min_fill, escrow: 0, decimals, } @@ -128,7 +128,6 @@ impl TokenPlaceAskTransitionActionV0 { order_id, amount: *amount, price: *price, - min_fill: *min_fill, decimals, order_document, token_market_contract: drive 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 db8821c1c75..c8a8ee6ad0a 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 @@ -1401,16 +1401,17 @@ impl Drive { }; let order = TokenOrder::from_document(&document, TokenOrderSide::Ask)?; + // The decimals are the token's when the listing was placed, which + // the platform recorded and the proof holds; the transition does + // not name them. if order.token_id != token_id || order.maker_id != owner_id || order.price != token_place_ask_transition.price() - || order.decimals != token_place_ask_transition.decimals() || order.amount != token_place_ask_transition.amount() // Fills lower what is open, never to 0 (a listing filled // entirely is deleted) nor above what was listed. || order.remaining == 0 || order.remaining > order.amount - || order.min_fill != token_place_ask_transition.min_fill() { return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( "proof of the listing {} does not hold the terms of state transition (token place ask)", diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 34bf210977e..ff63c05c332 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -129,7 +129,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// /// 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 +/// offers for it, with the token's minimum fill (`min_fill`, from 1 to `i64::MAX`, else +/// `InvalidTokenOrderFillRangeError` 10463 unpaid on contract create and update, and charged +/// on a `TokenConfigUpdate`). 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`, @@ -212,13 +214,17 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds /// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, /// as software that cannot decode them refuses them. The checks that read nothing beyond the -/// contract (a tradeable token, its decimals) run with the batch -/// `advanced_structure` 2, so check_tx refuses them too; the others with state validation. -/// A listing's terms and what it is worth at the decimals it signs (at most `i64::MAX` credits, -/// `InvalidTokenOrderValueError` 10465), are checked with the transition's structure, unpaid. A listing signs the decimals -/// its price is at and is refused unless they are the token's (`TokenOrderDecimalsMismatchError` -/// 40758, with the batch `advanced_structure`); it records them and its fills are priced at -/// them; a listing of a token its contract does not define bumps the nonce with the token base +/// contract (a tradeable token, an amount of at least its minimum fill, what the listing is +/// worth at its decimals: at most `i64::MAX` credits, `InvalidTokenOrderValueError` 10465) run +/// with the batch `advanced_structure` 2, so check_tx refuses them too; the others with state +/// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the +/// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records +/// the token's decimals as the token has them when it is placed (the token market contract's +/// `generatedFrom` `sys.tokens.decimals`, note 8), and its fills are priced at them; the +/// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is +/// read by every fill as the token has it then, and a listing of less is refused +/// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not +/// define bumps the nonce with the token base /// rules' error. An order of a token with 39 decimals or more (decimals are a `u8`, and a /// `Conventions` update is not held to the 16 a contract is) has a whole token of more base /// units than any amount times any price: it is worth one credit rounded up, so a listing's diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index c787b75635d..67c2fb834aa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -597,15 +597,10 @@ fn summarize_token_transition( } (None, None, None, t.public_note()) } - // A listing moves `amount` tokens into escrow; its price, at the decimals it signs, and - // its terms are details. + // A listing moves `amount` tokens into escrow; its price, per whole token at the token's + // decimals when it is placed, is a detail. TokenTransition::PlaceAsk(t) => { - lines.push(budget.write(format_args!( - "price: {} credits per whole token at {} decimals", - t.price(), - t.decimals() - ))?); - lines.push(budget.write(format_args!("minimum fill: {}", t.min_fill()))?); + lines.push(budget.write(format_args!("price: {} credits per whole token", t.price()))?); (Some(t.amount()), None, None, None) } // A fill spends at most `max_total_price` credits for at most `max_amount` tokens. @@ -694,8 +689,6 @@ mod tests { use dpp::state_transition::batch_transition::token_emergency_action_transition::v0::TokenEmergencyActionTransitionV0; use dpp::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::v0::TokenSetPriceForDirectPurchaseTransitionV0; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; - use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; - use dpp::state_transition::batch_transition::TokenPlaceAskTransition; use dpp::state_transition::batch_transition::{ BatchTransitionV1, DocumentCreateTransition, TokenClaimTransition, TokenConfigUpdateTransition, TokenDirectPurchaseTransition, @@ -1018,41 +1011,6 @@ mod tests { /// Two config updates granting manual minting to different takers must not /// summarize the same; neither is complete from the typed fields alone. - /// A listing's row names the decimals it signs: two listings differing only in them render - /// different approvals (1,000 base units at 1,000,000,000 credits per whole token are worth - /// 10,000 credits at 8 decimals and 1 credit at 16). - #[test] - fn listing_rows_show_the_signed_decimals() { - let listing = |decimals: u8| { - BatchedTransition::Token(TokenTransition::PlaceAsk(TokenPlaceAskTransition::V0( - TokenPlaceAskTransitionV0 { - base: token_base(), - amount: 1_000, - price: 1_000_000_000, - decimals, - min_fill: 1, - }, - ))) - }; - let summary = summarize_bytes(&batch(vec![listing(8), listing(16)])); - let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { - panic!("expected a batch"); - }; - assert_ne!(transitions[0], transitions[1]); - let details = |row: &BatchedTransitionSummary| row.details.clone().expect("details"); - assert!( - details(&transitions[0]).contains("at 8 decimals"), - "{}", - details(&transitions[0]) - ); - assert!( - details(&transitions[1]).contains("at 16 decimals"), - "{}", - details(&transitions[1]) - ); - assert_eq!(transitions[0].amount, Some(1_000)); - } - #[test] fn token_details_cover_config_update_emergency_price_and_claim() { let config_update = |takers: AuthorizedActionTakers| { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 85e35484aa2..0a49f22d3b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -897,8 +897,7 @@ public struct DataContractParser { // Parse marketplace rules if let marketplaceRules = tokenDict["marketplaceRules"] as? [String: Any] { - if let tradeModeStr = marketplaceRules["tradeMode"] as? String, - let tradeMode = TokenTradeMode(rawValue: tradeModeStr) { + if let tradeMode = parseTokenTradeMode(marketplaceRules["tradeMode"]) { token.tradeMode = tradeMode } token.tradeModeChangeRules = parseChangeControlRule(marketplaceRules["tradeModeChangeRules"]) @@ -914,6 +913,20 @@ public struct DataContractParser { } } + /// rs-dpp writes `TokenTradeMode` externally tagged: `NotTradeable` as a + /// bare string, and `Tradeable` (protocol version 15) as an object holding + /// its fields, `{"Tradeable": {"minFill": 1000}}`. The variant name, the + /// string or the object's single key, is the mode; `minFill` is not kept. + private static func parseTokenTradeMode(_ value: Any?) -> TokenTradeMode? { + if let name = value as? String { + return TokenTradeMode(rawValue: name) + } + if let tagged = value as? [String: Any], tagged.count == 1, let name = tagged.keys.first { + return TokenTradeMode(rawValue: name) + } + return nil + } + private static func parseChangeControlRule(_ ruleData: Any?) -> ChangeControlRules? { guard let ruleContainer = ruleData as? [String: Any] else { return nil } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index cd22aefcdfe..311047ea78e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -241,8 +241,14 @@ public enum TokenDistributionRecipient: String, CaseIterable, Codable, Sendable // MARK: - Token Trade Mode -/// Trading modes for tokens. Raw values match rs-dpp's `TokenTradeMode` in -/// contract JSON. `tradeable` is valid from protocol version 15. +/// Trading modes for tokens. Raw values are the variant names of rs-dpp's +/// `TokenTradeMode`. `tradeable` is valid from protocol version 15. +/// +/// Contract JSON carries `NotTradeable` as a bare string and `Tradeable` as +/// an object holding its `minFill`, `{"Tradeable": {"minFill": 1000}}`: the +/// smallest amount, in base units, a fill of one of the token's listings or +/// offers takes unless it takes all the order has left. Only the mode is +/// kept here; `minFill` is not persisted. /// /// Stored on `PersistentToken` as its raw string, so a new case leaves the /// entity hash unchanged. The frozen schema copies keep only `notTradeable`. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift index cfa9bc730e8..63b2297cda1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift @@ -5,20 +5,22 @@ import SwiftData /// Coverage for `DataContractParser`'s token trade mode parsing. /// /// rs-dpp serialises `TokenMarketplaceRules` flat, tagged with its format -/// version, and `TokenTradeMode` as a bare string: +/// version, and `TokenTradeMode` externally tagged: `NotTradeable` as a bare +/// string, `Tradeable` as an object holding its `minFill` (a JSON number, or a +/// decimal string above 2^53 - 1): /// /// ```json /// "marketplaceRules": { /// "$formatVersion": "0", -/// "tradeMode": "Tradeable", +/// "tradeMode": { "Tradeable": { "minFill": 1000 } }, /// "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. +/// "Tradeable" is valid from protocol version 15. A parser reading only a +/// string `tradeMode` misses the object, so the token keeps the +/// `notTradeable` default and `PersistentToken.isTradeable` reports false +/// for a tradeable token. `minFill` itself is not persisted. /// /// These tests drive the public `parseDataContract` entry point against an /// in-memory `ModelContainer` (no network, no FFI handle), save, and read the @@ -75,7 +77,7 @@ final class DataContractParserTradeModeTests: XCTestCase { /// A token configuration whose `marketplaceRules` carries `tradeMode` /// in the real rs-dpp wire shape. - private func tokenDict(tradeMode: String) -> [String: Any] { + private func tokenDict(tradeMode: Any) -> [String: Any] { return [ "$formatVersion": "0", "baseSupply": 0, @@ -96,7 +98,8 @@ final class DataContractParserTradeModeTests: XCTestCase { } func testParsesTradeableTradeModeFromContractJSON() throws { - try withParsedToken(tokenDict(tradeMode: "Tradeable")) { token in + let tradeable: [String: Any] = ["Tradeable": ["minFill": 1000]] + try withParsedToken(tokenDict(tradeMode: tradeable)) { token in XCTAssertEqual(token.tradeMode, .tradeable) XCTAssertTrue(token.isTradeable) XCTAssertEqual(token.tradeMode.displayName, "Tradeable") @@ -108,6 +111,16 @@ final class DataContractParserTradeModeTests: XCTestCase { } } + /// Above 2^53 - 1 rs-dpp writes `minFill` as a decimal string; the mode + /// is read from the variant name alone, whatever its fields hold. + func testTradeableWithStringMinFillParsesAsTradeable() throws { + let tradeable: [String: Any] = ["Tradeable": ["minFill": "9223372036854775807"]] + try withParsedToken(tokenDict(tradeMode: tradeable)) { token in + XCTAssertEqual(token.tradeMode, .tradeable) + XCTAssertTrue(token.isTradeable) + } + } + func testNotTradeableTradeModeStaysNotTradeable() throws { try withParsedToken(tokenDict(tradeMode: "NotTradeable")) { token in XCTAssertEqual(token.tradeMode, .notTradeable) @@ -116,8 +129,9 @@ final class DataContractParserTradeModeTests: XCTestCase { } } - /// The raw values are the strings rs-dpp writes; a rename on either side - /// would silently fall back to the default again. + /// The raw values are the variant names rs-dpp writes (the bare string, + /// or the object's single key); 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") diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs index 7085068e6af..008494b4abc 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -2,7 +2,7 @@ use crate::error::WasmDppResult; use crate::impl_try_from_js_value; use crate::impl_wasm_type_info; use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; -use crate::utils::{try_from_options, try_from_options_with, try_to_u64, try_to_u8}; +use crate::utils::{try_from_options, try_from_options_with, try_to_u64}; use dpp::balances::credits::TokenAmount; use dpp::fee::Credits; use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; @@ -14,17 +14,15 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const TOKEN_PLACE_ASK_OPTIONS_TS: &str = r#" /** - * Lists `amount` of the token for sale at `price` credits per whole token. The tokens move into - * escrow until the listing is filled or cancelled. A fill takes at least `minFill`, unless it - * takes everything left. + * Lists `amount` of the token for sale at `price` credits per whole token, at the token's + * decimals when the listing is placed, which it keeps. The tokens move into escrow until the + * listing is filled or cancelled. A fill takes at least the token's minimum fill, unless it + * takes everything left; `amount` must be at least that minimum. */ export interface TokenPlaceAskTransitionOptions { base: TokenBaseTransition; amount: bigint; price: bigint; - /** The token's decimals the price is at; the listing is refused unless they are the token's. */ - decimals: number; - minFill: bigint; } "#; @@ -63,19 +61,11 @@ impl TokenPlaceAskTransitionWasm { let price: Credits = try_from_options_with(&options, "price", |v| try_to_u64(v, "price"))?; - let decimals: u8 = - try_from_options_with(&options, "decimals", |v| try_to_u8(v, "decimals"))?; - - let min_fill: TokenAmount = - try_from_options_with(&options, "minFill", |v| try_to_u64(v, "minFill"))?; - Ok(TokenPlaceAskTransitionWasm(TokenPlaceAskTransition::V0( TokenPlaceAskTransitionV0 { base: base.into(), amount, price, - decimals, - min_fill, }, ))) } @@ -95,16 +85,6 @@ impl TokenPlaceAskTransitionWasm { self.0.price() } - #[wasm_bindgen(getter = decimals)] - pub fn decimals(&self) -> u8 { - self.0.decimals() - } - - #[wasm_bindgen(getter = minFill)] - pub fn min_fill(&self) -> TokenAmount { - self.0.min_fill() - } - #[wasm_bindgen(setter = base)] pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { self.0.set_base(base.into()) @@ -123,21 +103,6 @@ impl TokenPlaceAskTransitionWasm { self.0.set_price(try_to_u64(price, "price")?); Ok(()) } - - #[wasm_bindgen(setter = decimals)] - pub fn set_decimals(&mut self, decimals: &js_sys::Number) -> WasmDppResult<()> { - self.0.set_decimals(try_to_u8(decimals, "decimals")?); - Ok(()) - } - - #[wasm_bindgen(setter = minFill)] - pub fn set_min_fill( - &mut self, - #[wasm_bindgen(js_name = "minFill")] min_fill: &js_sys::BigInt, - ) -> WasmDppResult<()> { - self.0.set_min_fill(try_to_u64(min_fill, "minFill")?); - Ok(()) - } } impl_try_from_js_value!(TokenPlaceAskTransitionWasm, "TokenPlaceAskTransition"); diff --git a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs index 43c6dba93bc..18eee039f68 100644 --- a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs +++ b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs @@ -1,4 +1,6 @@ +use crate::error::WasmDppResult; use crate::impl_wasm_type_info; +use crate::utils::try_to_u64; use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use wasm_bindgen::prelude::wasm_bindgen; @@ -25,19 +27,30 @@ impl TokenTradeModeWasm { TokenTradeModeWasm(TokenTradeMode::NotTradeable) } - /// Holders may list the token and make offers for it (protocol version 15). + /// Holders may list the token and make offers for it (protocol version 15), each fill taking + /// at least `minFill` base units unless it takes everything an order has left. #[wasm_bindgen(js_name = "Tradeable")] - pub fn tradeable() -> TokenTradeModeWasm { - TokenTradeModeWasm(TokenTradeMode::Tradeable) + pub fn tradeable( + #[wasm_bindgen(js_name = "minFill")] min_fill: &js_sys::BigInt, + ) -> WasmDppResult { + Ok(TokenTradeModeWasm(TokenTradeMode::Tradeable { + min_fill: try_to_u64(min_fill, "minFill")?, + })) } #[wasm_bindgen(getter = "value")] pub fn value(&self) -> String { match self.0 { TokenTradeMode::NotTradeable => String::from("NotTradeable"), - TokenTradeMode::Tradeable => String::from("Tradeable"), + TokenTradeMode::Tradeable { .. } => String::from("Tradeable"), } } + + /// The smallest fill of the token's orders, `undefined` when it is not tradeable. + #[wasm_bindgen(getter = "minFill")] + pub fn min_fill(&self) -> Option { + self.0.min_fill() + } } impl_wasm_type_info!(TokenTradeModeWasm, TokenTradeMode); diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 2006c7cb2a5..1359391d72c 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -424,7 +424,12 @@ describe('TokenConfiguration', () => { describe('tradeMode', () => { it('should name both trade modes', () => { expect(wasm.TokenTradeMode.NotTradeable().value).to.equal('NotTradeable'); - expect(wasm.TokenTradeMode.Tradeable().value).to.equal('Tradeable'); + expect(wasm.TokenTradeMode.Tradeable(BigInt(100)).value).to.equal('Tradeable'); + }); + + it('should carry the minimum fill of a tradeable token', () => { + expect(wasm.TokenTradeMode.Tradeable(BigInt(100)).minFill).to.equal(BigInt(100)); + expect(wasm.TokenTradeMode.NotTradeable().minFill).to.equal(undefined); }); it('should keep a tradeable mode in the marketplace rules', () => { @@ -436,7 +441,7 @@ describe('TokenConfiguration', () => { isSelfChangingAdminActionTakersAllowed: false, }); const marketplaceRules = new wasm.TokenMarketplaceRules( - wasm.TokenTradeMode.Tradeable(), + wasm.TokenTradeMode.Tradeable(BigInt(1)), changeRules, ); diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index 7a33cba908a..628ac1ba817 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -788,8 +788,6 @@ describe('TokenTransitions', () => { base: baseTransition, amount: BigInt(1000), price: BigInt(100), - decimals: 8, - minFill: BigInt(1), }); it('should set the integer terms', () => { @@ -797,37 +795,12 @@ describe('TokenTransitions', () => { transition.amount = BigInt(2000); transition.price = BigInt(200); - transition.minFill = BigInt(10); expect(transition.amount).to.equal(BigInt(2000)); expect(transition.price).to.equal(BigInt(200)); - expect(transition.minFill).to.equal(BigInt(10)); - expect(transition.decimals).to.equal(8); - - transition.decimals = 2; - - expect(transition.decimals).to.equal(2); - }); - - it('should refuse decimals outside a byte and keep the previous ones', () => { - const transition = listing(); - - [256, -1, 1.5].forEach((value) => { - expect(() => { transition.decimals = value; }).to.throw(); - expect(transition.decimals).to.equal(8); - }); - }); - - it('should refuse a listing without decimals', () => { - expect(() => new wasm.TokenPlaceAskTransition({ - base: baseTransition, - amount: BigInt(1000), - price: BigInt(100), - minFill: BigInt(1), - })).to.throw(); }); - ['amount', 'price', 'minFill'].forEach((term) => { + ['amount', 'price'].forEach((term) => { it(`should refuse an out-of-range ${term} and keep the previous one`, () => { const transition = listing(); const previous = transition[term]; From aaba8919e7d680cbdeff34b43c705db6fa995bf3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 15:46:02 +0300 Subject: [PATCH 083/121] docs(platform-version): indent note 10's continuation under its two-digit number Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-version/src/version/v15.rs | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ff63c05c332..862403666ba 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -209,28 +209,28 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// /// 10. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount -/// of a tradeable token at a price in credits per whole token and moves it into escrow), -/// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the -/// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds -/// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, -/// as software that cannot decode them refuses them. The checks that read nothing beyond the -/// contract (a tradeable token, an amount of at least its minimum fill, what the listing is -/// worth at its decimals: at most `i64::MAX` credits, `InvalidTokenOrderValueError` 10465) run -/// with the batch `advanced_structure` 2, so check_tx refuses them too; the others with state -/// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the -/// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records -/// the token's decimals as the token has them when it is placed (the token market contract's -/// `generatedFrom` `sys.tokens.decimals`, note 8), and its fills are priced at them; the -/// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is -/// read by every fill as the token has it then, and a listing of less is refused -/// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not -/// define bumps the nonce with the token base -/// rules' error. An order of a token with 39 decimals or more (decimals are a `u8`, and a -/// `Conventions` update is not held to the 16 a contract is) has a whole token of more base -/// units than any amount times any price: it is worth one credit rounded up, so a listing's -/// first fill pays 1 and the rest 0, and nothing rounded down. Their structure and state -/// validations and their high-level operation converters are 0 in every table, which only this -/// version reaches. +/// of a tradeable token at a price in credits per whole token and moves it into escrow), +/// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the +/// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds +/// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, +/// as software that cannot decode them refuses them. The checks that read nothing beyond the +/// contract (a tradeable token, an amount of at least its minimum fill, what the listing is +/// worth at its decimals: at most `i64::MAX` credits, `InvalidTokenOrderValueError` 10465) run +/// with the batch `advanced_structure` 2, so check_tx refuses them too; the others with state +/// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the +/// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records +/// the token's decimals as the token has them when it is placed (the token market contract's +/// `generatedFrom` `sys.tokens.decimals`, note 8), and its fills are priced at them; the +/// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is +/// read by every fill as the token has it then, and a listing of less is refused +/// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not +/// define bumps the nonce with the token base +/// rules' error. An order of a token with 39 decimals or more (decimals are a `u8`, and a +/// `Conventions` update is not held to the 16 a contract is) has a whole token of more base +/// units than any amount times any price: it is worth one credit rounded up, so a listing's +/// first fill pays 1 and the rest 0, and nothing rounded down. Their structure and state +/// validations and their high-level operation converters are 0 in every table, which only this +/// version reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, 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 9) From 59fba488f408564afdddac53ceee4bb5737d7c15 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 18:13:12 +0300 Subject: [PATCH 084/121] 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 085/121] 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 From d3f40934b82a24f7db747d2d522585b6494ac2b4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 13:02:30 +0300 Subject: [PATCH 086/121] style(platform-version): format the drive version 10 table Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/drive_versions/v10.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3f5989a72ad..e7b622918c9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -6,8 +6,8 @@ use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_ID 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::drive_state_transition_method_versions::v5::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5; +use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; use crate::version::drive_versions::drive_verify_method_versions::v4::DRIVE_VERIFY_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_vote_method_versions::v4::DRIVE_VOTE_METHOD_VERSIONS_V4; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; From 1589b401e782e1f1ba3292e88bee3ca03cf597c7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 13:14:24 +0300 Subject: [PATCH 087/121] docs(platform-version): renumber the token listings note to 14 v5.1-dev's consume note took 11, so the generated decimals note is 12, the marketplace state 13 and the listings 14. 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 876427d91a8..863bcfa2305 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -284,7 +284,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// -/// 13. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// 14. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount /// of a tradeable token at a price in credits per whole token and moves it into escrow), /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds @@ -296,7 +296,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the /// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records /// the token's decimals as the token has them when it is placed (the token market contract's -/// `generatedFrom` `sys.tokens.decimals`, note 11), and its fills are priced at them; the +/// `generatedFrom` `sys.tokens.decimals`, note 12), and its fills are priced at them; the /// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is /// read by every fill as the token has it then, and a listing of less is refused /// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not From 50331e9217b1a6a19a4adf95714d25df4369edc2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 16:32:39 +0300 Subject: [PATCH 088/121] fix(drive)!: estimate a batch's fee the same whatever its operations' order (PV15) A batch's fee estimate keeps one description per layer, and in apply_drive_operations 1 every operation writes into the same map, so the operation built last sets the depth every operation at that layer is priced at: an identity contract nonce update (the root at level 0) built after a prefunded specialized balance write (the root at level 3) lowers the estimate of both. apply_drive_operations 2, selected only by DRIVE_VERSION_V10 (protocol version 15), lowers each operation of an estimated batch with a layer map of its own and merges each description into the batch's with util::estimated_layers::merge_estimated_layer: the deeper depth, then at that depth the nodes GroveDB's average_case_merk_propagate prices dearer, then a structural tie-break. Applying a batch is generation 1's. The estimate rises for any batch whose operations describe a shared layer differently, an ordinary document transition included; the fee a transition pays does not move. The sponsored token payment of nullifier_batch_guard estimates +171,440 processing credits, and the valid-payment settled digest, which covers the estimate, is re-pinned. Split out of the token marketplace's escrow PR (#5526), which builds on it. v15 note 12. Co-Authored-By: Claude Opus 5.5 --- .../src/state-transitions/drive-operations.md | 11 + .../token/shielded/nullifier_batch_guard.rs | 8 +- .../apply_drive_operations/mod.rs | 11 +- .../apply_drive_operations/v1/mod.rs | 506 +++++++++--------- .../apply_drive_operations/v2/mod.rs | 333 ++++++++++++ .../rs-drive/src/util/estimated_layers.rs | 306 +++++++++++ packages/rs-drive/src/util/mod.rs | 3 + .../src/version/drive_versions/v10.rs | 15 +- .../rs-platform-version/src/version/v15.rs | 17 +- 9 files changed, 962 insertions(+), 248 deletions(-) create mode 100644 packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs create mode 100644 packages/rs-drive/src/util/estimated_layers.rs diff --git a/book/src/state-transitions/drive-operations.md b/book/src/state-transitions/drive-operations.md index 6e6442750e0..946f112c3b0 100644 --- a/book/src/state-transitions/drive-operations.md +++ b/book/src/state-transitions/drive-operations.md @@ -284,6 +284,17 @@ This is used during `check_tx` and fee estimation. The platform needs to know approximately how much a transition will cost *before* actually applying it, both for balance pre-checks and for returning fee estimates to clients. +The map keeps one description per layer, and GroveDB prices every operation at that +layer's path with it. In `apply_drive_operations` generation 1 (protocol version 14 and +earlier) every operation writes into the same map, so whichever operation describes a +layer last sets the depth the whole batch is priced at: a nonce update (the root at level +0) built after a prefunded specialized balance write (the root at level 3) lowers the +estimate of both. From protocol version 15, generation 2 lowers each operation with a +map of its own and merges it into the batch's (`merge_estimated_layer`): the deeper +depth and, at that depth, the description GroveDB's propagation estimate prices dearer. +The estimate no longer depends on the order of the batch's operations, and no operation +is priced below its own description. Applying a batch is unchanged. + ## The Fee Calculation At the end of `apply_drive_operations`, fees are calculated from the accumulated 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 5afe657671d..0df31b95b21 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,11 +1867,13 @@ 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_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) + (138_024_000, 117_713_120, 126_468_000, 3_870_440) }; assert_eq!( result.execution_results().as_slice(), @@ -2162,7 +2164,7 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat ), "valid-payment" => ( "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "f6694678f1b835c476af2012fd217c67524f4f8d3ca5e4f9facbcec0b205081b", + "b89ffc216dee6c414f44e41ebd1f34e503326a1361d7b7304ee591682edc79b8", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", 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..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,7 +262,10 @@ 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)` 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 { continue; @@ -293,6 +296,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 +378,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 +481,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 +541,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 new file mode 100644 index 00000000000..20cdd2948ed --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -0,0 +1,333 @@ +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::merge_estimated_layer; +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: an identity contract nonce update (the root at level 0) + /// built after a prefunded specialized balance write (the root at level 3) lowered the + /// batch's estimate of the root, so the estimate depended on the order of the batch's + /// operations. Here 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: 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, + 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, 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( + 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() { + merge_estimated_layer(batch_layers, path, layer, platform_version)?; + } + 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, 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; + 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") + } + + /// 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 nodes GroveDB prices + /// dearer, 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 + /// 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-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs new file mode 100644 index 00000000000..23c4ff88664 --- /dev/null +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -0,0 +1,306 @@ +//! 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 crate::error::Error; +use crate::fees::op::DriveCost; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +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; + +/// 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, + 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())))?; + propagation + .cost + .ephemeral_cost(&platform_version.fee_version) +} + +/// 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 { + // The operations of one batch mostly describe a shared layer alike: nothing to price. + if a == b { + return Ok(*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()) + { + 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); + } + let by_price = + 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 +/// 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` ([`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> { + 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use grovedb::EstimatedLayerCount::EstimatedLevel; + use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; + use grovedb::EstimatedSumTrees::{AllBigSumTrees, 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), + } + } + + /// 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() { + let platform_version = PlatformVersion::latest(); + let path = KeyInfoPath::from_known_path([[1u8].as_slice()]); + 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, platform_version) + .expect("expected to merge"); + merge_estimated_layer(&mut estimation, path.clone(), second, platform_version) + .expect("expected to merge"); + estimation[&path] + }; + 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 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)); + 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)); + assert_ne!(items, references); + assert_eq!(credits(&items), credits(&references)); + assert_eq!(merged(items, references), merged(references, items)); + } +} 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/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index ad6c2849f65..d70da965594 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -3,7 +3,9 @@ use crate::version::drive_versions::drive_state_transition_method_versions::v5:: use crate::version::drive_versions::drive_verify_method_versions::v4::DRIVE_VERIFY_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_vote_method_versions::v4::DRIVE_VOTE_METHOD_VERSIONS_V4; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; -use crate::version::drive_versions::{DriveMethodVersions, DriveVersion}; +use crate::version::drive_versions::{ + DriveBatchOperationsMethodVersion, DriveMethodVersions, DriveVersion, +}; /// Drive version 10. /// Introduced in protocol v15: @@ -26,6 +28,13 @@ use crate::version::drive_versions::{DriveMethodVersions, DriveVersion}; /// sets `convert_to_high_level_operations.document_create_transition` to 1: a create deletes /// each document it consumes from what document reference validation 1 read of it, without /// reading it again. +/// * **Order-independent batch estimates**: `batch_operations.apply_drive_operations` 2 lowers +/// each operation of an estimated batch with layer descriptions of its own and merges them +/// into the batch's as one description covering both (the deeper depth, with the nodes +/// GroveDB prices dearer there), so a batch's estimate no longer depends on the order of its +/// operations and no operation is priced below its own description. It raises the estimate of +/// any batch whose operations describe a shared layer differently, an ordinary document +/// transition included. Applying a batch is unchanged. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -34,6 +43,10 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { vote: DRIVE_VOTE_METHOD_VERSIONS_V4, // changed in v10: the yes/no poll kind verify: DRIVE_VERIFY_METHOD_VERSIONS_V4, // changed in v10: the yes/no proof verifiers state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5, // changed in v10: a consume deletes what its lookup read + batch_operations: DriveBatchOperationsMethodVersion { + 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 + }, ..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 e2eebccff94..9aec0e26f04 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -240,9 +240,24 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// A consume costs one document read less: a DPNS v3 name create's processing fee drops by /// 10,660 credits. Storage fees and refunds are unchanged. Protocol v14 and earlier keep /// document reference validation 0 and the create conversion 0. +/// +/// 12. **A batch's fee estimate does not depend on its operations' order**: +/// `apply_drive_operations` 2 (`DRIVE_VERSION_V10`) lowers each operation of an estimated +/// batch with layer descriptions of its own and merges them into the batch's as one +/// description covering both: the deeper depth, and at that depth the nodes GroveDB's +/// `average_case_merk_propagate` prices dearer, the shallower description priced at the +/// deeper one's depth, then a fixed tie-break. Generation 1 kept the description of +/// whichever operation was built last, so a write describing a layer shallowly (an identity +/// contract nonce, the root at level 0) built after one describing it deeper (a prefunded +/// specialized balance, the root at level 3) lowered the estimate of both. The estimate +/// `validate_fees_of_event` checks a payer's balance against rises for 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, as applying a batch is generation 1's. Protocol v14 and +/// earlier keep `apply_drive_operations` 1. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11) + drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); apply_drive_operations 2 merges each operation's layer descriptions (note 12) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); 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 From a8ae26935b79968472508e412698656b7768c931 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 16:48:54 +0300 Subject: [PATCH 089/121] docs(platform-version): renumber the token listings note to 15 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 0eaf5356c1b..e1e9b1d875c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -298,7 +298,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. /// -/// 14. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// 15. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount /// of a tradeable token at a price in credits per whole token and moves it into escrow), /// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the /// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds @@ -310,7 +310,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the /// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records /// the token's decimals as the token has them when it is placed (the token market contract's -/// `generatedFrom` `sys.tokens.decimals`, note 12), and its fills are priced at them; the +/// `generatedFrom` `sys.tokens.decimals`, note 13), and its fills are priced at them; the /// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is /// read by every fill as the token has it then, and a listing of less is refused /// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not From 2177f075eb05efdb668ec56ed4c8fd97670a68dd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 17:15:26 +0300 Subject: [PATCH 090/121] fix(drive): pick a layer's description once the whole batch is described apply_drive_operations 2 merged each operation's description of a layer into the batch's two at a time, keeping the deeper depth and, at that depth, the nodes GroveDB prices dearer. Which of two descriptions is dearer can change with the depth they are priced at (at level 0 GroveDB prices every description alike, so the tie-break chose), so a description discarded early could have been the dearer at a depth seen later, and the result depended on the order of the batch's operations. Generation 2 now keeps every description its operations give a layer (add_estimated_layer_candidates) and, once the whole batch is lowered, describes each layer by the costliest of them (costliest_estimated_layers): the deepest depth, then at that depth the nodes GroveDB prices dearest, each description priced at every count that reaches that depth, then the structural tie-break. The pick is a maximum over the whole set. Tests check each case in every order, including two level 0 descriptions priced alike with a level 4 one. Co-Authored-By: Claude Opus 5.5 --- .../src/state-transitions/drive-operations.md | 11 +- .../apply_drive_operations/v2/mod.rs | 50 +-- .../rs-drive/src/util/estimated_layers.rs | 296 ++++++++++++------ .../src/version/drive_versions/v10.rs | 10 +- .../rs-platform-version/src/version/v15.rs | 9 +- 5 files changed, 245 insertions(+), 131 deletions(-) diff --git a/book/src/state-transitions/drive-operations.md b/book/src/state-transitions/drive-operations.md index 946f112c3b0..ce4c6293af5 100644 --- a/book/src/state-transitions/drive-operations.md +++ b/book/src/state-transitions/drive-operations.md @@ -290,10 +290,13 @@ earlier) every operation writes into the same map, so whichever operation descri layer last sets the depth the whole batch is priced at: a nonce update (the root at level 0) built after a prefunded specialized balance write (the root at level 3) lowers the estimate of both. From protocol version 15, generation 2 lowers each operation with a -map of its own and merges it into the batch's (`merge_estimated_layer`): the deeper -depth and, at that depth, the description GroveDB's propagation estimate prices dearer. -The estimate no longer depends on the order of the batch's operations, and no operation -is priced below its own description. Applying a batch is unchanged. +map of its own, keeps every description the operations give a layer, and once the whole +batch is lowered describes the layer by the costliest of them +(`costliest_estimated_layers`): the deepest depth and, at that depth, the description +GroveDB's propagation estimate prices dearest. Picking only once every description is +known matters, because which of two descriptions is dearer can change with the depth +they are priced at. The estimate no longer depends on the order of the batch's +operations, and no operation is priced below its own description. Applying a batch is unchanged. ## The Fee Calculation 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 20cdd2948ed..b0b47f94757 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 @@ -8,30 +8,32 @@ use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::fee::Credits; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::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::merge_estimated_layer; +use crate::util::estimated_layers::{ + add_estimated_layer_candidates, costliest_estimated_layers, LayerCandidates, +}; 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 + /// gathered 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: an identity contract nonce update (the root at level 0) /// built after a prefunded specialized balance write (the root at level 3) lowered the /// batch's estimate of the root, so the estimate depended on the order of the batch's - /// operations. Here 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 + /// operations. Here each operation is lowered with a map of its own, and every description + /// it gives a layer is kept until the whole batch is lowered; the layer is then described + /// by the costliest of them (`costliest_estimated_layers`: the deepest depth any of them + /// has, and at that depth the nodes GroveDB's propagation estimate prices dearest), 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: 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 @@ -100,11 +102,9 @@ impl Drive { )?; } let mut low_level_operations = vec![]; - let mut estimated_costs_only_with_layer_info = if apply { - None::> - } else { - Some(HashMap::new()) - }; + // An estimate keeps every description each operation gives a layer until the whole + // batch is lowered, then describes the layer by the costliest of them. + let mut layer_candidates = (!apply).then(LayerCandidates::new); // The document operations of a moderator's deletion, whose removals refund nobody let mut forfeited_low_level_operations = vec![]; @@ -118,11 +118,10 @@ 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, 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() { + // An estimate lowers each operation into layer descriptions of its own, kept beside + // the other operations' until the batch is lowered: the batch's estimate is the same + // whichever order its operations come in. + let mut converted = match layer_candidates.as_mut() { None => drive_op.into_low_level_drive_operations_after_ttl_drain( self, &mut None, @@ -130,7 +129,7 @@ impl Drive { transaction, platform_version, )?, - Some(batch_layers) => { + Some(candidates) => { let mut operation_layers = Some(HashMap::new()); let converted = drive_op.into_low_level_drive_operations_after_ttl_drain( self, @@ -139,9 +138,10 @@ impl Drive { transaction, platform_version, )?; - for (path, layer) in operation_layers.unwrap_or_default() { - merge_estimated_layer(batch_layers, path, layer, platform_version)?; - } + add_estimated_layer_candidates( + candidates, + operation_layers.unwrap_or_default(), + ); converted } }; @@ -152,6 +152,10 @@ impl Drive { } } + let estimated_costs_only_with_layer_info = layer_candidates + .map(|candidates| costliest_estimated_layers(candidates, platform_version)) + .transpose()?; + let repaid_identity_debt = LowLevelDriveOperation::take_repaid_identity_debt(&mut low_level_operations)?; diff --git a/packages/rs-drive/src/util/estimated_layers.rs b/packages/rs-drive/src/util/estimated_layers.rs index 23c4ff88664..c7ca505a5f4 100644 --- a/packages/rs-drive/src/util/estimated_layers.rs +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -4,7 +4,11 @@ //! 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. +//! Which of two descriptions GroveDB prices dearer can change with the depth they are priced +//! at, so a batch keeps every description its writes give a layer until all of them are known, +//! and only then picks one. +use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::DriveCost; use dpp::fee::Credits; @@ -15,7 +19,6 @@ use grovedb::{ TreeType, }; use grovedb_merk::estimated_costs::average_case_costs::average_case_merk_propagate; -use std::cmp::Ordering; use std::collections::HashMap; /// What GroveDB's own estimator charges to carry a change through a layer described as `layer` @@ -35,48 +38,61 @@ fn propagation_credits( .ephemeral_cost(&platform_version.fee_version) } -/// The description a batch keeps for a layer two of its operations describe as `a` and `b`. GroveDB +/// The description a batch keeps for a layer its operations describe as `candidates`. 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 +/// them: it is as deep as the deepest (`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, +/// and at that depth it has the nodes GroveDB prices dearest ([`propagation_credits`]), each +/// candidate being priced at every count that reaches that depth. Two priced alike are told +/// apart by their contents ([`layer_key`]). The pick is a maximum over the whole set, so it is +/// the same whatever order the candidates were gathered in; picking between two at a time +/// would not be, as a description priced alike or cheaper at a shallow depth can be the +/// dearer at a deeper one. +fn costliest_layer( + candidates: &[EstimatedLayerInformation], platform_version: &PlatformVersion, ) -> Result { + let Some(first) = candidates.first() else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a layer is described by at least one candidate", + ))); + }; // The operations of one batch mostly describe a shared layer alike: nothing to price. - if a == b { - return Ok(*a); + if candidates.iter().all(|candidate| candidate == first) { + return Ok(*first); } - let at_depth_of = |layer: &EstimatedLayerInformation, deeper: &EstimatedLayerInformation| { - EstimatedLayerInformation { - estimated_layer_count: deeper.estimated_layer_count, - ..*layer + let deepest = candidates + .iter() + .map(|candidate| candidate.estimated_layer_count.estimate_levels()) + .fold(0, u32::max); + let mut deepest_counts: Vec = vec![]; + for candidate in candidates { + let count = candidate.estimated_layer_count; + if count.estimate_levels() == deepest && !deepest_counts.contains(&count) { + deepest_counts.push(count); } - }; - let (a, b) = match a - .estimated_layer_count - .estimate_levels() - .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); } - let by_price = - 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 }) + let mut costliest: Option<(Credits, Vec, EstimatedLayerInformation)> = None; + for candidate in candidates { + for count in &deepest_counts { + let at_depth = EstimatedLayerInformation { + estimated_layer_count: *count, + ..*candidate + }; + let price = propagation_credits(&at_depth, platform_version)?; + let key = layer_key(&at_depth); + let dearer = match &costliest { + None => true, + Some((costliest_price, costliest_key, _)) => { + (price, &key) > (*costliest_price, costliest_key) + } + }; + if dearer { + costliest = Some((price, key, at_depth)); + } + } + } + Ok(costliest.map_or(*first, |(_, _, layer)| layer)) } /// A total order on descriptions, for two GroveDB prices alike: every field in turn, each enum @@ -206,29 +222,42 @@ fn sum_trees_key(sum_trees: EstimatedSumTrees, key: &mut Vec) { } } -/// 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, +/// Every description the operations of a batch give each layer, kept until the batch is lowered. +pub(crate) type LayerCandidates = HashMap>; + +/// Adds the descriptions one operation of a batch gives its layers to the batch's candidates, +/// each description once. +pub(crate) fn add_estimated_layer_candidates( + candidates: &mut LayerCandidates, + operation_layers: HashMap, +) { + for (path, layer) in operation_layers { + let described = candidates.entry(path).or_default(); + if !described.contains(&layer) { + described.push(layer); + } + } +} + +/// The description a batch keeps for each layer its operations describe: the costliest of the +/// layer's candidates ([`costliest_layer`]). The order two operations of a batch register their +/// descriptions of a shared layer in does not matter, and the description priced for each of +/// them is never cheaper than its own. +pub(crate) fn costliest_estimated_layers( + candidates: LayerCandidates, platform_version: &PlatformVersion, -) -> Result<(), Error> { - 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(()) +) -> Result, Error> { + candidates + .into_iter() + .map(|(path, layers)| Ok((path, costliest_layer(&layers, platform_version)?))) + .collect() } #[cfg(test)] mod tests { use super::*; - use grovedb::EstimatedLayerCount::EstimatedLevel; - use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; + use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; + use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::{AllBigSumTrees, NoSumTrees}; use grovedb::TreeType; @@ -240,67 +269,144 @@ mod tests { } } - /// 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() { - let platform_version = PlatformVersion::latest(); + /// Every order of `layers`, each from an operation of its own. + fn orders(layers: &[EstimatedLayerInformation]) -> Vec> { + if layers.len() <= 1 { + return vec![layers.to_vec()]; + } + let mut all = vec![]; + for (index, layer) in layers.iter().enumerate() { + let mut rest = layers.to_vec(); + rest.remove(index); + for mut order in orders(&rest) { + order.insert(0, *layer); + all.push(order); + } + } + all + } + + /// The description a batch keeps when its operations describe one layer as `layers`, in + /// that order, each from an operation of its own. + fn kept(layers: &[EstimatedLayerInformation]) -> EstimatedLayerInformation { let path = KeyInfoPath::from_known_path([[1u8].as_slice()]); - let at_depth_3 = |estimated_layer_sizes| EstimatedLayerInformation { + let mut candidates = LayerCandidates::new(); + for layer in layers { + add_estimated_layer_candidates( + &mut candidates, + HashMap::from([(path.clone(), *layer)]), + ); + } + costliest_estimated_layers(candidates, PlatformVersion::latest()) + .expect("expected to pick a description")[&path] + } + + /// The description `layers` give a layer, the same in every order they come in. + fn kept_in_every_order(layers: &[EstimatedLayerInformation]) -> EstimatedLayerInformation { + let kept_first = kept(layers); + for order in orders(layers) { + assert_eq!(kept(&order), kept_first, "order {order:?}"); + } + kept_first + } + + fn credits(layer: &EstimatedLayerInformation) -> Credits { + propagation_credits(layer, PlatformVersion::latest()).expect("expected a price") + } + + fn at_level( + level: u32, + estimated_layer_sizes: EstimatedLayerSizes, + ) -> EstimatedLayerInformation { + EstimatedLayerInformation { + estimated_layer_count: EstimatedLevel(level, false), estimated_layer_sizes, - ..layer(3) - }; - let merged = |first: EstimatedLayerInformation, second: EstimatedLayerInformation| { - let mut estimation = HashMap::new(); - merge_estimated_layer(&mut estimation, path.clone(), first, platform_version) - .expect("expected to merge"); - merge_estimated_layer(&mut estimation, path.clone(), second, platform_version) - .expect("expected to merge"); - estimation[&path] - }; - let credits = |layer: &EstimatedLayerInformation| { - propagation_credits(layer, platform_version).expect("expected a price") - }; + ..layer(level) + } + } + /// The deepest depth, the nodes GroveDB's propagation estimate prices dearest at that depth, + /// then, for two it prices alike, a fixed choice; the same in every order. + #[test] + fn should_keep_the_costliest_description_of_a_layer_in_every_order() { // 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)); + let items = at_level(3, AllItems(32, 8, None)); + let subtrees = at_level(3, AllSubtrees(32, AllBigSumTrees, None)); assert!(credits(&items) > credits(&subtrees)); - assert_eq!(merged(items, subtrees), items); - assert_eq!(merged(subtrees, items), items); + assert_eq!(kept_in_every_order(&[items, subtrees]), items); - // A deeper description of narrower nodes, priced below the shallower one: the merge + // A deeper description of narrower nodes, priced below the shallower one: the batch // 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)) - }; + let deeper_narrow = at_level(4, AllSubtrees(1, NoSumTrees, None)); assert!(credits(&deeper_narrow) < credits(&items)); - 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); + let deeper_items = at_level(4, AllItems(32, 8, None)); + assert_eq!(kept_in_every_order(&[items, deeper_narrow]), 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); + let deeper_wide = at_level(4, AllItems(32, 64, None)); + assert_eq!(kept_in_every_order(&[items, deeper_wide]), deeper_wide); // Two different descriptions GroveDB prices alike. - let references = at_depth_3(AllReference(32, 8, None)); + let references = at_level(3, AllReference(32, 8, None)); assert_ne!(items, references); assert_eq!(credits(&items), credits(&references)); - assert_eq!(merged(items, references), merged(references, items)); + kept_in_every_order(&[items, references]); + } + + /// At level 0 GroveDB prices every description alike, so a choice between two level 0 + /// descriptions made before a deeper one arrives would rest on the tie-break, and could keep + /// the nodes that are the cheaper at the deeper depth. The batch keeps all three until the + /// deepest depth is known, then the nodes dearest there, in every order. + #[test] + fn should_pick_among_shallow_descriptions_at_the_deepest_depth() { + let items = at_level(0, AllItems(32, 8, None)); + let mix = at_level( + 0, + Mix { + subtrees_size: Some((32, AllBigSumTrees, None, 1)), + items_size: None, + references_size: None, + items_with_sum_item_size: None, + references_with_sum_item_size: None, + }, + ); + let deep_narrow = at_level(4, AllSubtrees(1, NoSumTrees, None)); + assert_eq!(credits(&items), credits(&mix)); + let items_at_4 = at_level(4, AllItems(32, 8, None)); + let mix_at_4 = EstimatedLayerInformation { + estimated_layer_count: EstimatedLevel(4, false), + ..mix + }; + assert!(credits(&items_at_4) > credits(&mix_at_4)); + assert!(credits(&items_at_4) > credits(&deep_narrow)); + assert_eq!(kept_in_every_order(&[items, mix, deep_narrow]), items_at_4); + } + + /// Two descriptions of one depth with different counts: each candidate is priced at each + /// count, and the batch keeps the dearest, in either order. + #[test] + fn should_price_every_candidate_at_every_count_of_the_deepest_depth() { + let items = at_level(2, AllItems(32, 8, None)); + let by_count = EstimatedLayerInformation { + estimated_layer_count: ApproximateElements(3), + ..at_level(2, AllSubtrees(1, NoSumTrees, None)) + }; + assert_eq!(by_count.estimated_layer_count.estimate_levels(), 2); + let kept = kept_in_every_order(&[items, by_count]); + assert_eq!(kept.estimated_layer_sizes, AllItems(32, 8, None)); + assert!(credits(&kept) >= credits(&items)); + assert!(credits(&kept) >= credits(&by_count)); + } + + /// A layer every operation describes alike is kept as described, without pricing. + #[test] + fn should_keep_a_layer_described_alike_as_it_is() { + let items = at_level(3, AllItems(32, 8, None)); + assert_eq!(kept(&[items, items, items]), items); } } 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 d70da965594..017b207f3e9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -29,10 +29,10 @@ use crate::version::drive_versions::{ /// each document it consumes from what document reference validation 1 read of it, without /// reading it again. /// * **Order-independent batch estimates**: `batch_operations.apply_drive_operations` 2 lowers -/// each operation of an estimated batch with layer descriptions of its own and merges them -/// into the batch's as one description covering both (the deeper depth, with the nodes -/// GroveDB prices dearer there), so a batch's estimate no longer depends on the order of its -/// operations and no operation is priced below its own description. It raises the estimate of +/// each operation of an estimated batch with layer descriptions of its own and, once the whole +/// batch is lowered, describes each layer by the costliest of them (the deepest depth, with the +/// nodes GroveDB prices dearest there), so a batch's estimate no longer depends on the order of +/// its operations and no operation is priced below its own description. It raises the estimate of /// any batch whose operations describe a shared layer differently, an ordinary document /// transition included. Applying a batch is unchanged. /// @@ -44,7 +44,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { verify: DRIVE_VERIFY_METHOD_VERSIONS_V4, // changed in v10: the yes/no proof verifiers state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5, // changed in v10: a consume deletes what its lookup read batch_operations: DriveBatchOperationsMethodVersion { - apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both + apply_drive_operations: 2, // changed in v10: an estimate describes each layer by the costliest of its operations' descriptions ..DRIVE_VERSION_V9.methods.batch_operations }, ..DRIVE_VERSION_V9.methods diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 9aec0e26f04..b7243c4564f 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -243,10 +243,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// /// 12. **A batch's fee estimate does not depend on its operations' order**: /// `apply_drive_operations` 2 (`DRIVE_VERSION_V10`) lowers each operation of an estimated -/// batch with layer descriptions of its own and merges them into the batch's as one -/// description covering both: the deeper depth, and at that depth the nodes GroveDB's -/// `average_case_merk_propagate` prices dearer, the shallower description priced at the -/// deeper one's depth, then a fixed tie-break. Generation 1 kept the description of +/// batch with layer descriptions of its own and keeps every description they give a layer +/// until the whole batch is lowered, then describes the layer by the costliest of them: the +/// deepest depth any of them has, and at that depth the nodes GroveDB's +/// `average_case_merk_propagate` prices dearest, each description priced at that depth, +/// then a fixed tie-break. Generation 1 kept the description of /// whichever operation was built last, so a write describing a layer shallowly (an identity /// contract nonce, the root at level 0) built after one describing it deeper (a prefunded /// specialized balance, the root at level 3) lowered the estimate of both. The estimate From 19ae32db98274d2c79f469c94d83c5dfb685494b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 17:36:12 +0300 Subject: [PATCH 091/121] test(drive): pin apply_drive_operations 1 at protocol version 14 should_keep_refunding_a_batch_without_the_forfeiture_before_protocol_version_14 pinned generation 1 at the latest version; it now pins it at 14 and checks the latest keeps at least generation 1, which still forfeits. The contract fee pot estimation 0 and other_tree.rs comments note that from protocol version 15 each operation is estimated with a map of its own and the batch keeps the costliest description. Co-Authored-By: Claude Opus 5.5 --- .../contract/fee_pots/estimated_costs/v0/mod.rs | 6 +++++- .../contract/moderation/document_removal_tests.rs | 12 +++++++++++- packages/rs-drive/src/drive/contract/other_tree.rs | 5 ++++- 3 files changed, 20 insertions(+), 3 deletions(-) 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..354b62b2d8d 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,11 @@ 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 keeps the costliest of its description and + // 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/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/contract/other_tree.rs b/packages/rs-drive/src/drive/contract/other_tree.rs index a2561d5963e..d6bc518f80a 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, and the batch keeps the costliest of this + /// description and 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, From 8e24d95c7c553e20cbb150c28ed8e6d945852a1f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 17:44:07 +0300 Subject: [PATCH 092/121] test(drive-abci): describe the estimate pin's generation as it now picks Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/shielded/nullifier_batch_guard.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 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 0df31b95b21..8f6a74deaba 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,8 +1867,8 @@ 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. + // Both estimates are apply_drive_operations 2's (protocol version 15), which describes + // each layer by the costliest of its operations' descriptions. let (estimated_storage, estimated_processing, actual_storage, actual_processing) = if shielded { (171_126_000, 213_389_040, 155_142_000, 92_739_560) From 2600480c7cf92bd0ccaf065b98c1b01a10e50148 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 17:45:30 +0300 Subject: [PATCH 093/121] fix(kotlin-example-app): store an unknown token trade mode as NotTradeable TokenMaterializer read any bare string or any tagged object's single key as the trade mode, and the token screen treats every mode other than NotTradeable as tradeable, so a malformed or newer mode showed as tradeable. Only NotTradeable and Tradeable are accepted now; any other name parses to null, which the materializer stores as NotTradeable, as Swift's TokenTradeMode(rawValue:) refuses it. Co-Authored-By: Claude Opus 5.5 --- .../services/tokens/TokenMaterializer.kt | 12 +++++++++--- .../tokens/TokenMaterializerTradeModeTest.kt | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt index c0d0abe04cc..6c791252846 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt @@ -208,16 +208,22 @@ object TokenMaterializer { private fun JsonObject.rule(key: String): ChangeControlRules? = (this[key] as? JsonObject)?.let(ChangeControlRules::parse) + /** The `TokenTradeMode` variants rs-dpp writes. */ + private val TRADE_MODES = setOf("NotTradeable", "Tradeable") + /** * rs-dpp writes `TokenTradeMode` externally tagged: `NotTradeable` as a * bare string, and `Tradeable` (protocol version 15) as an object holding * its fields, `{"Tradeable":{"minFill":1000}}`. The variant name, the * string or the object's single key, is the mode the column keeps; - * `minFill` is not persisted. Port of `parseTokenTradeMode` in - * `DataContractParser.swift`. + * `minFill` is not persisted. Any other name is refused (`null`, which + * the caller stores as `NotTradeable`), so a malformed or newer mode is + * never shown as tradeable. Port of `parseTokenTradeMode` in + * `DataContractParser.swift`, whose `TokenTradeMode(rawValue:)` refuses + * it the same way. */ private fun JsonObject.tradeMode(key: String): String? = - str(key) ?: obj(key)?.keys?.singleOrNull() + (str(key) ?: obj(key)?.keys?.singleOrNull())?.takeIf { it in TRADE_MODES } /** * u64-safe supply extraction — port of `extractTokenSupply` diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt index f83dbc7a8ca..7fd500f55ce 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt @@ -10,7 +10,8 @@ import org.junit.Test * writes `TokenTradeMode` externally tagged: `NotTradeable` as a bare * string, `Tradeable` (protocol version 15) as an object holding its * `minFill` (a JSON number, or a decimal string above 2^53 - 1). The - * variant name lands in [TokenEntity.tradeMode]; `minFill` is not kept. + * variant name lands in [TokenEntity.tradeMode]; `minFill` is not kept. Any + * other name is stored as `NotTradeable`. * Mirrors `DataContractParserTradeModeTests.swift`. */ class TokenMaterializerTradeModeTest { @@ -52,4 +53,18 @@ class TokenMaterializerTradeModeTest { assertEquals("NotTradeable", token.tradeMode) } + + @Test + fun `an unknown bare mode is stored as NotTradeable`() { + val token = parseSingleToken("\"Auctioned\"") + + assertEquals("NotTradeable", token.tradeMode) + } + + @Test + fun `an unknown tagged mode is stored as NotTradeable`() { + val token = parseSingleToken("""{"Auctioned":{"minFill":1}}""") + + assertEquals("NotTradeable", token.tradeMode) + } } From 1edb1c002a190b210b8391a17e7c20ca38b7acce Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 20:10:06 +0300 Subject: [PATCH 094/121] fix(drive): refuse an estimated operation that leaves no layer map Generation 2 of apply_drive_operations read a missing per-operation layer map as empty, which would drop that operation's descriptions and lower the batch's estimate. No operation clears the map today, so the refusal cannot fire and no estimate changes. Also corrects the history in v15 note 12 and the book: protocol v14 keeps apply_drive_operations 1, protocol v13 and earlier keep 0. Co-Authored-By: Claude Opus 5.5 --- book/src/state-transitions/drive-operations.md | 10 +++++----- .../drive_methods/apply_drive_operations/v2/mod.rs | 14 +++++++++----- packages/rs-platform-version/src/version/v15.rs | 5 +++-- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/book/src/state-transitions/drive-operations.md b/book/src/state-transitions/drive-operations.md index ce4c6293af5..8a2073740d0 100644 --- a/book/src/state-transitions/drive-operations.md +++ b/book/src/state-transitions/drive-operations.md @@ -285,11 +285,11 @@ approximately how much a transition will cost *before* actually applying it, bot balance pre-checks and for returning fee estimates to clients. The map keeps one description per layer, and GroveDB prices every operation at that -layer's path with it. In `apply_drive_operations` generation 1 (protocol version 14 and -earlier) every operation writes into the same map, so whichever operation describes a -layer last sets the depth the whole batch is priced at: a nonce update (the root at level -0) built after a prefunded specialized balance write (the root at level 3) lowers the -estimate of both. From protocol version 15, generation 2 lowers each operation with a +layer's path with it. In `apply_drive_operations` generations 0 (protocol version 13 and +earlier) and 1 (protocol version 14) every operation writes into the same map, so +whichever operation describes a layer last sets the depth the whole batch is priced at: +a nonce update (the root at level 0) built after a prefunded specialized balance write +(the root at level 3) lowers the estimate of both. From protocol version 15, generation 2 lowers each operation with a map of its own, keeps every description the operations give a layer, and once the whole batch is lowered describes the layer by the costliest of them (`costliest_estimated_layers`): the deepest depth and, at that depth, the description 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 b0b47f94757..522d1f88fd2 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 @@ -1,7 +1,7 @@ use crate::util::batch::{DriveOperation, SystemOperationType}; use crate::drive::Drive; -use crate::error::{fee::FeeError, Error}; +use crate::error::{drive::DriveError, fee::FeeError, Error}; use crate::fees::op::LowLevelDriveOperation; use dpp::block::block_info::BlockInfo; @@ -138,10 +138,14 @@ impl Drive { transaction, platform_version, )?; - add_estimated_layer_candidates( - candidates, - operation_layers.unwrap_or_default(), - ); + // An operation that left no map would drop its descriptions and lower the + // batch's estimate, so it is refused rather than read as describing nothing. + let operation_layers = operation_layers.ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "an estimated operation leaves its layer descriptions behind", + ), + ))?; + add_estimated_layer_candidates(candidates, operation_layers); converted } }; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index b7243c4564f..0519cba10f4 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -254,8 +254,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// `validate_fees_of_event` checks a payer's balance against rises for 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, as applying a batch is generation 1's. Protocol v14 and -/// earlier keep `apply_drive_operations` 1. +/// transition pays is unchanged, as applying a batch is generation 1's. Protocol v14 keeps +/// `apply_drive_operations` 1 and protocol v13 and earlier keep 0; both describe a layer +/// by whichever operation is built last. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); apply_drive_operations 2 merges each operation's layer descriptions (note 12) From d4cc944e93ea6e06aa4280e0132e0648893be408 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 20:13:10 +0300 Subject: [PATCH 095/121] docs(book): rewrap the batch estimate paragraph Co-Authored-By: Claude Opus 5.5 --- book/src/state-transitions/drive-operations.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/book/src/state-transitions/drive-operations.md b/book/src/state-transitions/drive-operations.md index 8a2073740d0..0b3ea2132f9 100644 --- a/book/src/state-transitions/drive-operations.md +++ b/book/src/state-transitions/drive-operations.md @@ -289,14 +289,14 @@ layer's path with it. In `apply_drive_operations` generations 0 (protocol versio earlier) and 1 (protocol version 14) every operation writes into the same map, so whichever operation describes a layer last sets the depth the whole batch is priced at: a nonce update (the root at level 0) built after a prefunded specialized balance write -(the root at level 3) lowers the estimate of both. From protocol version 15, generation 2 lowers each operation with a -map of its own, keeps every description the operations give a layer, and once the whole -batch is lowered describes the layer by the costliest of them +(the root at level 3) lowers the estimate of both. From protocol version 15, generation 2 +lowers each operation with a map of its own, keeps every description the operations give +a layer, and once the whole batch is lowered describes the layer by the costliest of them (`costliest_estimated_layers`): the deepest depth and, at that depth, the description GroveDB's propagation estimate prices dearest. Picking only once every description is known matters, because which of two descriptions is dearer can change with the depth -they are priced at. The estimate no longer depends on the order of the batch's -operations, and no operation is priced below its own description. Applying a batch is unchanged. +they are priced at. The estimate no longer depends on the order of the batch's operations, +and no operation is priced below its own description. Applying a batch is unchanged. ## The Fee Calculation From 8d31dbfb522b4c927bafad2880166be43d0627ec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 04:57:18 +0300 Subject: [PATCH 096/121] docs(platform-version): point the DPNS bindings note at note 15 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 ef94b472f87..55cea5baf1b 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -115,7 +115,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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 14.) +/// (The bindings move to 3 for the token market contract alone, note 15.) /// /// 4. **`countOf` and `sumOf` match byte array keys**: `CONTRACT_VERSIONS_V7` /// selects `parse_property_constraints` 1, whose grammar binds a filter key From 096a2cf3bb77fd6d8855b6a7b3c9b4ac9651cc37 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 05:35:06 +0300 Subject: [PATCH 097/121] test(drive-abci): pin protocol 15 fees with both new genesis contracts With the decision proposals and token market contracts both in the genesis contracts tree, the token contract's node is billed 48_640 more bytes to reach than at protocol 14 (burn 4_404_840, direct purchase 699_868_001_200), and the commit-reveal fixture's contract is read for as many bytes as at 14. Co-Authored-By: Claude Opus 5.5 --- .../tests/document/commit_reveal_lookup.rs | 22 +++++++++---------- .../batch/tests/token/burn/mod.rs | 8 +++---- .../batch/tests/token/direct_selling/mod.rs | 8 +++---- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs index 016cb66294e..f1d12b2d17c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs @@ -1763,15 +1763,13 @@ mod commit_reveal_lookup_tests { actual_fees.processing_fee, )); } - // A chain born at 15 also holds the decision proposals contract, one more sibling - // registered at genesis in the contracts tree, which reshapes it so the written post - // reads its contract for fewer bytes (-45_920). The lookups cost the same. - let ((found_at_14, found_storage_at_14), missing_at_14) = fees[0]; + // A chain born at 15 also holds the decision proposals and token market contracts, + // two more siblings registered at genesis in the contracts tree; with both, the + // written post reads its contract for as many bytes as at 14. The lookups cost the + // same. assert_eq!( - ((found_at_14 - 45_920, found_storage_at_14), missing_at_14), - fees[1], - "keyed by owner {keyed_by_owner}: the fees at 14 less the genesis shift, then at \ - 15" + fees[0], fees[1], + "keyed by owner {keyed_by_owner}: the fees at 14, then at 15" ); } } @@ -3007,10 +3005,10 @@ mod commit_reveal_lookup_tests { /// it consumes from what its lookup read, without reading it again. #[tokio::test] async fn should_pin_the_fees_of_a_reveal_choosing_the_first_of_its_preorders() { - // At 15 the contracts tree also holds the decision proposals contract, one more sibling - // registered at genesis, which reshapes it so the fixture contract's node is billed - // fewer bytes to reach (-45_920) - for (protocol_version, accepted_processing_fee) in [(14, 3_574_720), (15, 3_516_780)] { + // At 15 the contracts tree also holds the decision proposals and token market contracts, + // two more siblings registered at genesis; with both, the fixture contract's node is + // billed as many bytes to reach as at 14 + for (protocol_version, accepted_processing_fee) in [(14, 3_574_720), (15, 3_562_700)] { let platform_version = PlatformVersion::get(protocol_version).expect("expected the platform version"); let mut fixture = CommitRevealFixture::new_at(platform_version, |contract| { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index 5c543bf6fca..78242a65169 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -3963,10 +3963,10 @@ mod token_burn_tests { // +740 per document write from protocol version 14: the contract's version item is // one more node to rehash; -12_820: the documents expirations tree joins `Misc` // beside the token supplies tree the burn rewrites, reshaping the `Misc` Merk - // PROTOCOL_VERSION_15: -48_640: the decision proposals contract adds one - // sibling to the genesis contracts tree, which reshapes the tree so the token - // contract's node is billed fewer bytes to reach - 4_307_560, + // PROTOCOL_VERSION_15: +48_640: the decision proposals and token market contracts + // join the genesis contracts tree, which reshapes it so the token contract's node is + // billed more bytes to reach + 4_404_840, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs index 911fe3476d8..90730a76378 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs @@ -31,10 +31,10 @@ mod token_selling_tests { // reads the total supply even though the token sets no max supply. 12_820 credits // less in fees: the documents expirations tree joins `Misc` beside the token // supplies tree the purchase rewrites, reshaping the `Misc` Merk - // PROTOCOL_VERSION_15: 48_640 credits less in fees: the decision proposals - // contract adds one sibling to the genesis contracts tree, which reshapes the tree - // so the token contract's node is billed fewer bytes to reach - 699_868_098_480, + // PROTOCOL_VERSION_15: 48_640 credits more in fees: the decision proposals and token + // market contracts join the genesis contracts tree, which reshapes it so the token + // contract's node is billed more bytes to reach + 699_868_001_200, ) .await; } From eb27187466aea204518594e60e286f7bda12dc2c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 09:12:03 +0300 Subject: [PATCH 098/121] fix(drive)!: keep every layer description an estimate's writes give, nested ones included (PV15) Generation 2 of apply_drive_operations lowered each operation with a map of its own, but a map holds one description per path, so the nested estimators of one operation still overwrote each other before the batch saw them: a token mint's balance estimator (the root at level 2) replaced its supply estimator's description (level 3), and the mint was priced below its supply's own description. Every estimator now writes into EstimatedLayers instead of a bare HashMap. EstimatedLayers::new keeps the description written last, exactly as the map did, so every estimate and protocol version but apply_drive_operations 2 is unchanged. Generation 2 builds the batch's estimate with EstimatedLayers::keeping_every_description, which also keeps every description any write gives a path, and resolves it once the whole batch is lowered to the costliest description of each layer. The per-operation maps go away. The two estimators that write a description only when none is there use insert_if_absent, and an unresolved estimate is refused at the GroveDB boundary. The mechanical type change touches the signatures of shipped estimator generations; their behaviour is the map's. Co-Authored-By: Claude Opus 5.5 --- .../src/state-transitions/drive-operations.md | 30 +-- .../token/shielded/nullifier_batch_guard.rs | 4 +- .../state_transitions/identity_update/mod.rs | 4 +- .../add_balance_to_address/mod.rs | 13 +- .../add_balance_to_address/v0/mod.rs | 9 +- .../for_address_balance_update/mod.rs | 6 +- .../for_address_balance_update/v0/mod.rs | 4 +- .../for_address_balance_update/v1/mod.rs | 4 +- .../remove_balance_from_address/mod.rs | 9 +- .../remove_balance_from_address/v0/mod.rs | 9 +- .../set_balance_to_address/mod.rs | 12 +- .../set_balance_to_address/v0/mod.rs | 9 +- .../add_asset_lock_outpoint_operations/mod.rs | 8 +- .../v0/mod.rs | 8 +- .../v0/mod.rs | 4 +- .../drive/asset_lock/estimation_costs/mod.rs | 9 +- .../add_to_system_credits_operations/mod.rs | 10 +- .../v0/mod.rs | 10 +- .../mod.rs | 9 +- .../v0/mod.rs | 10 +- .../contract/apply/apply_contract/mod.rs | 9 +- .../contract/apply/apply_contract/v0/mod.rs | 9 +- .../apply_contract_with_serialization/mod.rs | 9 +- .../v0/mod.rs | 13 +- .../v1/mod.rs | 13 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../v1/mod.rs | 19 +- .../fee_pots/add_to_contract_fee_pot/mod.rs | 9 +- .../add_to_contract_fee_pot/v0/mod.rs | 10 +- .../deduct_from_contract_fee_pot/mod.rs | 9 +- .../deduct_from_contract_fee_pot/v0/mod.rs | 10 +- .../contract/fee_pots/estimated_costs/mod.rs | 6 +- .../fee_pots/estimated_costs/v0/mod.rs | 23 +- .../set_contract_last_fee_claim/mod.rs | 8 +- .../set_contract_last_fee_claim/v0/mod.rs | 10 +- .../insert/add_contract_to_storage/mod.rs | 10 +- .../insert/add_contract_to_storage/v0/mod.rs | 8 +- .../insert/add_contract_to_storage/v1/mod.rs | 8 +- .../contract/insert/add_description/mod.rs | 9 +- .../contract/insert/add_description/v0/mod.rs | 14 +- .../contract/insert/add_new_keywords/mod.rs | 9 +- .../insert/add_new_keywords/v0/mod.rs | 14 +- .../contract/insert/insert_contract/mod.rs | 9 +- .../contract/insert/insert_contract/v0/mod.rs | 17 +- .../tests/index_only_array_index_e2e_tests.rs | 5 +- .../v0/tests/index_only_e2e_tests.rs | 5 +- .../contract/insert/insert_contract/v1/mod.rs | 17 +- .../contract/insert/insert_contract/v2/mod.rs | 17 +- .../moderation/add_contract_ban/mod.rs | 9 +- .../moderation/add_contract_ban/v0/mod.rs | 13 +- .../add_contract_document_removal/mod.rs | 9 +- .../add_contract_document_removal/v0/mod.rs | 9 +- .../moderation/add_contract_suspension/mod.rs | 9 +- .../add_contract_suspension/v0/mod.rs | 13 +- .../add_contract_team_action_signature/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../moderation/add_contract_warning/mod.rs | 9 +- .../moderation/add_contract_warning/v0/mod.rs | 13 +- .../moderation/estimated_costs/mod.rs | 18 +- .../moderation/estimated_costs/v0/mod.rs | 16 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../insert_contract_moderation_trees/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../insert_contract_team_action_trees/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../moderation/remove_contract_ban/mod.rs | 9 +- .../moderation/remove_contract_ban/v0/mod.rs | 13 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../remove_contract_suspension/mod.rs | 9 +- .../remove_contract_suspension/v0/mod.rs | 13 +- .../remove_contract_warnings/mod.rs | 9 +- .../remove_contract_warnings/v0/mod.rs | 13 +- .../mod.rs | 8 +- .../v0/mod.rs | 10 +- .../rs-drive/src/drive/contract/other_tree.rs | 22 +- .../contract/update/update_contract/mod.rs | 9 +- .../contract/update/update_contract/v0/mod.rs | 16 +- .../contract/update/update_contract/v1/mod.rs | 16 +- .../contract/update/update_contract/v2/mod.rs | 16 +- .../contract/update/update_description/mod.rs | 9 +- .../update/update_description/v0/mod.rs | 13 +- .../contract/update/update_keywords/mod.rs | 9 +- .../contract/update/update_keywords/v0/mod.rs | 14 +- .../for_insert_contract_group/mod.rs | 6 +- .../for_insert_contract_group/v0/mod.rs | 6 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../insert/insert_contract_group/mod.rs | 9 +- .../insert/insert_contract_group/v0/mod.rs | 13 +- .../insert_contract_group_memberships/mod.rs | 9 +- .../v0/mod.rs | 14 +- .../src/drive/contract_groups/tests.rs | 7 +- .../delete_document_for_contract/v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../delete_document_for_contract_id/v0/mod.rs | 10 +- .../mod.rs | 9 +- .../v0/mod.rs | 10 +- .../mod.rs | 43 ++-- .../v0/mod.rs | 21 +- .../mod.rs | 9 +- .../v0/mod.rs | 10 +- .../mod.rs | 13 +- .../v0/mod.rs | 14 +- .../mod.rs | 6 +- .../v0/mod.rs | 5 +- .../rs-drive/src/drive/document/delete/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 10 +- .../mod.rs | 9 +- .../v0/mod.rs | 7 +- .../v1/mod.rs | 7 +- .../v2/mod.rs | 7 +- .../mod.rs | 10 +- .../v0/mod.rs | 6 +- .../v1/mod.rs | 6 +- .../v2/mod.rs | 6 +- .../mod.rs | 10 +- .../v0/mod.rs | 7 +- .../v1/mod.rs | 7 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../add_document_expiration_operations/mod.rs | 9 +- .../v0/mod.rs | 8 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../v0/mod.rs | 9 +- .../mod.rs | 13 +- .../v0/mod.rs | 9 +- .../v1/mod.rs | 21 +- .../add_document_to_primary_storage/mod.rs | 11 +- .../add_document_to_primary_storage/v0/mod.rs | 8 +- .../insert/add_history_operations/mod.rs | 9 +- .../insert/add_history_operations/v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 7 +- .../v1/mod.rs | 7 +- .../v2/mod.rs | 7 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../v1/mod.rs | 6 +- .../v2/mod.rs | 6 +- .../mod.rs | 10 +- .../tests.rs | 4 +- .../mod.rs | 10 +- .../v0/mod.rs | 11 +- .../v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../v1/mod.rs | 8 +- .../mod.rs | 10 +- .../v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../v1/mod.rs | 6 +- .../mod.rs | 3 +- .../v0/mod.rs | 3 +- .../mod.rs | 10 +- .../v0/mod.rs | 7 +- .../mod.rs | 10 +- .../v0/mod.rs | 7 +- .../v1/mod.rs | 7 +- .../document/summable_off_count_counter.rs | 16 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../mod.rs | 13 +- .../v0/mod.rs | 9 +- .../v1/mod.rs | 9 +- .../update_document_for_contract/v0/mod.rs | 9 +- .../update_document_for_contract_id/v0/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../for_add_group_action/mod.rs | 6 +- .../for_add_group_action/v0/mod.rs | 4 +- .../estimated_costs/for_add_groups/mod.rs | 7 +- .../estimated_costs/for_add_groups/v0/mod.rs | 5 +- .../mod.rs | 9 +- .../v0/mod.rs | 12 +- .../drive/group/fetch/fetch_group_info/mod.rs | 9 +- .../group/fetch/fetch_group_info/v0/mod.rs | 12 +- .../group/insert/add_group_action/mod.rs | 9 +- .../group/insert/add_group_action/v0/mod.rs | 13 +- .../drive/group/insert/add_new_groups/mod.rs | 10 +- .../group/insert/add_new_groups/v0/mod.rs | 14 +- .../merge_identity_contract_nonce/mod.rs | 9 +- .../merge_identity_contract_nonce/v0/mod.rs | 19 +- .../mod.rs | 9 +- .../v0/mod.rs | 13 +- .../v1/mod.rs | 13 +- .../mod.rs | 9 +- .../v0/mod.rs | 13 +- .../v1/mod.rs | 13 +- .../mod.rs | 6 +- .../v0/mod.rs | 5 +- .../estimation_costs/for_balances/mod.rs | 6 +- .../estimation_costs/for_balances/v0/mod.rs | 4 +- .../for_identity_contract_info/mod.rs | 6 +- .../for_identity_contract_info/v0/mod.rs | 4 +- .../for_identity_contract_info_group/mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../for_keys_for_identity_id/mod.rs | 6 +- .../for_keys_for_identity_id/v0/mod.rs | 4 +- .../for_negative_credit/mod.rs | 6 +- .../for_negative_credit/v0/mod.rs | 4 +- .../for_purpose_in_key_reference_tree/mod.rs | 6 +- .../v0/mod.rs | 4 +- .../for_root_key_reference_tree/mod.rs | 6 +- .../for_root_key_reference_tree/v0/mod.rs | 4 +- .../estimation_costs/for_update_nonce/mod.rs | 6 +- .../for_update_nonce/v0/mod.rs | 4 +- .../for_update_revision/mod.rs | 6 +- .../for_update_revision/v0/mod.rs | 4 +- .../identity/insert/add_new_identity/mod.rs | 9 +- .../insert/add_new_identity/v0/mod.rs | 14 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../budget/insert_identity_key_budget/mod.rs | 9 +- .../insert_identity_key_budget/v0/mod.rs | 9 +- .../insert/create_key_tree_with_keys/mod.rs | 9 +- .../create_key_tree_with_keys/v0/mod.rs | 9 +- .../mod.rs | 8 +- .../v0/mod.rs | 8 +- .../insert_key_searchable_references/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../insert/insert_new_non_unique_key/mod.rs | 9 +- .../insert_new_non_unique_key/v0/mod.rs | 9 +- .../insert_new_non_unique_key/v1/mod.rs | 9 +- .../key/insert/insert_new_unique_key/mod.rs | 9 +- .../insert/insert_new_unique_key/v0/mod.rs | 9 +- .../insert/insert_new_unique_key/v1/mod.rs | 9 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../insert_reference_to_non_unique_key/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../insert_reference_to_unique_key/mod.rs | 9 +- .../insert_reference_to_unique_key/v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../methods/add_new_keys_to_identity/mod.rs | 13 +- .../add_new_keys_to_identity/v0/mod.rs | 13 +- .../v0/mod.rs | 9 +- .../add_new_unique_keys_to_identity/v0/mod.rs | 9 +- .../methods/add_to_identity_balance/mod.rs | 9 +- .../methods/add_to_identity_balance/v0/mod.rs | 17 +- .../methods/add_to_identity_balance/v1/mod.rs | 13 +- .../v0/mod.rs | 8 +- .../v1/mod.rs | 8 +- .../methods/disable_identity_keys/mod.rs | 9 +- .../methods/disable_identity_keys/v0/mod.rs | 13 +- .../methods/disable_identity_keys/v1/mod.rs | 13 +- .../methods/merge_identity_nonce/mod.rs | 9 +- .../methods/merge_identity_nonce/v0/mod.rs | 9 +- .../methods/re_enable_identity_keys/mod.rs | 9 +- .../methods/re_enable_identity_keys/v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../remove_from_identity_balance/mod.rs | 9 +- .../remove_from_identity_balance/v0/mod.rs | 13 +- .../methods/update_identity_key_limits/mod.rs | 9 +- .../update_identity_key_limits/v0/mod.rs | 13 +- .../methods/update_identity_revision/mod.rs | 9 +- .../update_identity_revision/v0/mod.rs | 9 +- .../rs-drive/src/drive/identity/update/mod.rs | 5 +- .../merge_identity_nonce_operations/v0/mod.rs | 9 +- .../update_identity_revision_operation/mod.rs | 2 +- .../v0/mod.rs | 10 +- .../mod.rs | 9 +- .../v0/mod.rs | 13 +- .../v1/mod.rs | 14 +- .../mod.rs | 9 +- .../v0/mod.rs | 13 +- .../v1/mod.rs | 14 +- .../mod.rs | 9 +- .../v0/mod.rs | 14 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../v1/mod.rs | 4 +- .../mod.rs | 7 +- .../v0/mod.rs | 4 +- .../mod.rs | 7 +- .../v0/mod.rs | 4 +- .../mod.rs | 8 +- .../v0/mod.rs | 4 +- .../mod.rs | 8 +- .../v0/mod.rs | 4 +- .../src/drive/shielded/estimated_costs.rs | 4 +- .../for_total_system_credits_update/mod.rs | 6 +- .../for_total_system_credits_update/v0/mod.rs | 4 +- .../add_transaction_history_operations/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../src/drive/tokens/apply_status/mod.rs | 8 +- .../src/drive/tokens/apply_status/v0/mod.rs | 15 +- .../add_to_previous_token_balance/mod.rs | 9 +- .../add_to_previous_token_balance/v0/mod.rs | 13 +- .../remove_from_identity_token_balance/mod.rs | 9 +- .../v0/mod.rs | 13 +- .../rs-drive/src/drive/tokens/burn/mod.rs | 8 +- .../rs-drive/src/drive/tokens/burn/v0/mod.rs | 15 +- .../set_direct_purchase_price/mod.rs | 16 +- .../set_direct_purchase_price/v0/mod.rs | 10 +- .../add_once_per_identity_distribution/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../add_perpetual_distribution/mod.rs | 9 +- .../add_perpetual_distribution/v0/mod.rs | 9 +- .../add_pre_programmed_distribution/mod.rs | 9 +- .../add_pre_programmed_distribution/v0/mod.rs | 9 +- .../add_pre_programmed_distribution/v1/mod.rs | 9 +- .../mod.rs | 8 +- .../v0/mod.rs | 9 +- .../mod.rs | 8 +- .../v0/mod.rs | 6 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../estimated_costs/for_token_balances/mod.rs | 6 +- .../for_token_balances/v0/mod.rs | 4 +- .../for_token_contract_infos/mod.rs | 6 +- .../for_token_contract_infos/v0/mod.rs | 4 +- .../for_token_direct_selling_prices/mod.rs | 6 +- .../for_token_direct_selling_prices/v0/mod.rs | 4 +- .../for_token_identity_infos/mod.rs | 6 +- .../for_token_identity_infos/v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../for_token_perpetual_distribution/mod.rs | 6 +- .../v0/mod.rs | 4 +- .../mod.rs | 6 +- .../v0/mod.rs | 4 +- .../for_token_status_infos/mod.rs | 6 +- .../for_token_status_infos/v0/mod.rs | 4 +- .../for_token_total_supply/mod.rs | 6 +- .../for_token_total_supply/v0/mod.rs | 4 +- .../rs-drive/src/drive/tokens/freeze/mod.rs | 8 +- .../src/drive/tokens/freeze/v0/mod.rs | 15 +- .../rs-drive/src/drive/tokens/mint/mod.rs | 8 +- .../rs-drive/src/drive/tokens/mint/v0/mod.rs | 15 +- .../src/drive/tokens/mint_many/mod.rs | 8 +- .../src/drive/tokens/mint_many/v0/mod.rs | 18 +- .../shielded_pool/burn_from_pool/mod.rs | 9 +- .../shielded_pool/burn_from_pool/v0/mod.rs | 9 +- .../shielded_pool/create_pool_trees/mod.rs | 9 +- .../shielded_pool/create_pool_trees/v0/mod.rs | 9 +- .../tokens/shielded_pool/estimated_costs.rs | 4 +- .../tokens/shielded_pool/mint_to_pool/mod.rs | 9 +- .../shielded_pool/mint_to_pool/v0/mod.rs | 9 +- .../src/drive/tokens/shielded_pool/mod.rs | 14 +- .../drive/tokens/shielded_pool/shield/mod.rs | 9 +- .../tokens/shielded_pool/shield/v0/mod.rs | 9 +- .../shielded_pool/shielded_transfer/mod.rs | 9 +- .../shielded_pool/shielded_transfer/v0/mod.rs | 9 +- .../tokens/shielded_pool/unshield/mod.rs | 9 +- .../tokens/shielded_pool/unshield/v0/mod.rs | 9 +- .../system/add_to_token_total_supply/mod.rs | 9 +- .../add_to_token_total_supply/v0/mod.rs | 17 +- .../tokens/system/create_token_trees/mod.rs | 9 +- .../system/create_token_trees/v0/mod.rs | 13 +- .../mod.rs | 9 +- .../v0/mod.rs | 17 +- .../system/fetch_token_total_supply/mod.rs | 9 +- .../system/fetch_token_total_supply/v0/mod.rs | 19 +- .../remove_from_token_total_supply/mod.rs | 8 +- .../remove_from_token_total_supply/v0/mod.rs | 15 +- .../rs-drive/src/drive/tokens/transfer/mod.rs | 8 +- .../src/drive/tokens/transfer/v0/mod.rs | 15 +- .../rs-drive/src/drive/tokens/unfreeze/mod.rs | 8 +- .../src/drive/tokens/unfreeze/v0/mod.rs | 15 +- .../mod.rs | 9 +- .../v0/mod.rs | 6 +- .../v1/mod.rs | 8 +- .../yes_no/open_yes_no_vote_poll/mod.rs | 9 +- .../yes_no/open_yes_no_vote_poll/v0/mod.rs | 6 +- .../batch/drive_op_batch/address_funds.rs | 9 +- .../src/util/batch/drive_op_batch/contract.rs | 9 +- .../batch/drive_op_batch/contract_fee_pot.rs | 9 +- .../batch/drive_op_batch/contract_group.rs | 9 +- .../drive_op_batch/contract_moderation.rs | 9 +- .../src/util/batch/drive_op_batch/document.rs | 18 +- .../apply_drive_operations/v0/mod.rs | 9 +- .../apply_drive_operations/v1/mod.rs | 9 +- .../apply_drive_operations/v2/mod.rs | 172 ++++++++------ .../src/util/batch/drive_op_batch/group.rs | 9 +- .../src/util/batch/drive_op_batch/identity.rs | 9 +- .../src/util/batch/drive_op_batch/mod.rs | 19 +- .../prefunded_specialized_balance.rs | 9 +- .../src/util/batch/drive_op_batch/shielded.rs | 9 +- .../src/util/batch/drive_op_batch/system.rs | 9 +- .../src/util/batch/drive_op_batch/token.rs | 9 +- .../util/batch/drive_op_batch/vote_poll.rs | 9 +- .../util/batch/drive_op_batch/withdrawals.rs | 8 +- .../rs-drive/src/util/estimated_layers.rs | 220 +++++++++++++++--- .../grove_batch_operations_costs/mod.rs | 7 +- .../grove_batch_operations_costs/v0/mod.rs | 10 +- packages/rs-drive/src/util/mod.rs | 4 +- .../apply_batch_grovedb_operations/mod.rs | 9 +- .../apply_batch_grovedb_operations/v0/mod.rs | 9 +- .../mod.rs | 9 +- .../v0/mod.rs | 9 +- .../v1/mod.rs | 9 +- .../mod.rs | 10 +- .../v0/mod.rs | 10 +- .../mod.rs | 10 +- .../v0/mod.rs | 10 +- .../src/version/drive_versions/v10.rs | 18 +- .../rs-platform-version/src/version/v15.rs | 39 ++-- 421 files changed, 1713 insertions(+), 2525 deletions(-) diff --git a/book/src/state-transitions/drive-operations.md b/book/src/state-transitions/drive-operations.md index 0b3ea2132f9..efcb7a9f327 100644 --- a/book/src/state-transitions/drive-operations.md +++ b/book/src/state-transitions/drive-operations.md @@ -284,19 +284,23 @@ This is used during `check_tx` and fee estimation. The platform needs to know approximately how much a transition will cost *before* actually applying it, both for balance pre-checks and for returning fee estimates to clients. -The map keeps one description per layer, and GroveDB prices every operation at that -layer's path with it. In `apply_drive_operations` generations 0 (protocol version 13 and -earlier) and 1 (protocol version 14) every operation writes into the same map, so -whichever operation describes a layer last sets the depth the whole batch is priced at: -a nonce update (the root at level 0) built after a prefunded specialized balance write -(the root at level 3) lowers the estimate of both. From protocol version 15, generation 2 -lowers each operation with a map of its own, keeps every description the operations give -a layer, and once the whole batch is lowered describes the layer by the costliest of them -(`costliest_estimated_layers`): the deepest depth and, at that depth, the description -GroveDB's propagation estimate prices dearest. Picking only once every description is -known matters, because which of two descriptions is dearer can change with the depth -they are priced at. The estimate no longer depends on the order of the batch's operations, -and no operation is priced below its own description. Applying a batch is unchanged. +The estimate (`EstimatedLayers`) hands GroveDB one description per layer, and GroveDB +prices every operation at that layer's path with it. In `apply_drive_operations` +generations 0 (protocol version 13 and earlier) and 1 (protocol version 14), and in every +other estimate, the description written last for a layer is the one kept, whether it was +written by another operation of the batch or by a nested estimator of the same operation: +a contract nonce update (the root at level 1) built after a prefunded specialized balance +write (the root at level 3) lowers the estimate of both, and a token mint's balance +estimator (the root at level 2) replaces its supply estimator's description (level 3). +From protocol version 15, generation 2 lowers the batch into +`EstimatedLayers::keeping_every_description`, which keeps every description any write +gives a layer, and once the whole batch is lowered `EstimatedLayers::resolve` describes +each layer by the costliest of them: the deepest depth and, at that depth, the +description GroveDB's propagation estimate prices dearest. Picking only once every +description is known matters, because which of two descriptions is dearer can change with +the depth they are priced at. The estimate no longer depends on the order of the batch's +operations, and no write is priced below its own description. Applying a batch is +unchanged. ## The Fee Calculation 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 f489e056c9d..44d526844cd 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 @@ -1868,7 +1868,7 @@ async fn should_preserve_a_real_single_payment_sponsor_on_first_check_recheck_an result.execution_results() ); // Both estimates are apply_drive_operations 2's (protocol version 15), which describes - // each layer by the costliest of its operations' descriptions. + // each layer by the costliest description any write of the batch gives it. let (estimated_storage, estimated_processing, actual_storage, actual_processing) = if shielded { (171_126_000, 213_389_040, 155_142_000, 92_785_480) @@ -2184,7 +2184,7 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "7ecda0074e06c9c66c7f856680e06d2c9bd391ea8746d3ed90263580b665c115", + "c68476bc881df5d44ec8ce84503eb24736abb56c8986079a47ab62f302c37108", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index 2c82bfeb3e4..f3e81a7c5df 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -1467,7 +1467,7 @@ mod tests { // operations: one real fee per bound group, not a fixed stand-in. use dpp::block::epoch::Epoch; use drive::fees::op::LowLevelDriveOperation; - use std::collections::HashMap; + use drive::util::estimated_layers::EstimatedLayers; let lookup_fees = |operations: &[LowLevelDriveOperation]| -> Vec { operations .iter() @@ -1480,7 +1480,7 @@ mod tests { let key_ids: Vec<_> = keys.iter().map(|key| key.id()).collect(); let revocation_operations = |estimate: bool| { platform.drive.cache.data_contracts.clear(); - let mut layer_info = estimate.then(HashMap::new); + let mut layer_info = estimate.then(EstimatedLayers::new); platform .drive .disable_identity_keys_operations( diff --git a/packages/rs-drive/src/drive/address_funds/add_balance_to_address/mod.rs b/packages/rs-drive/src/drive/address_funds/add_balance_to_address/mod.rs index e67768f1dd8..cd1f9825721 100644 --- a/packages/rs-drive/src/drive/address_funds/add_balance_to_address/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/add_balance_to_address/mod.rs @@ -4,12 +4,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Adds a balance for a given address in the AddressBalances tree. @@ -32,9 +31,7 @@ impl Drive { &self, address: PlatformAddress, balance: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -67,9 +64,7 @@ impl Drive { &self, address: PlatformAddress, balance: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/address_funds/add_balance_to_address/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/add_balance_to_address/v0/mod.rs index a19e4aeec27..1f1c1f8c2ce 100644 --- a/packages/rs-drive/src/drive/address_funds/add_balance_to_address/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/add_balance_to_address/v0/mod.rs @@ -2,13 +2,12 @@ use crate::drive::constants::AVERAGE_BALANCE_SIZE; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::{BatchInsertApplyType, QueryTarget}; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; +use grovedb::{TransactionArg, TreeType}; use platform_version::version::PlatformVersion; -use std::collections::HashMap; /// Average size of nonce stored with balance (8 bytes for u64) const AVERAGE_NONCE_SIZE: u32 = 8; @@ -32,9 +31,7 @@ impl Drive { &self, address: PlatformAddress, amount_to_add: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/mod.rs b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/mod.rs index 27e644169a0..163b4e6b35a 100644 --- a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/mod.rs @@ -4,10 +4,8 @@ mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds estimation costs for address balance updates. @@ -37,7 +35,7 @@ impl Drive { /// - `Ok(())` if successful. /// - `Err(DriveError::UnknownVersionMismatch)` if the method version doesn't match any known versions. pub(crate) fn add_estimation_costs_for_address_balance_update( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v0/mod.rs index e63ca81ff23..7fc3a7482ff 100644 --- a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v0/mod.rs @@ -1,11 +1,11 @@ use crate::drive::constants::AVERAGE_BALANCE_SIZE; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; use grovedb::EstimatedSumTrees::SomeSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; /// Size of a PlatformAddress key (1 byte type + 20 bytes hash) pub const PLATFORM_ADDRESS_KEY_SIZE: u32 = 21; @@ -25,7 +25,7 @@ impl Drive { /// * `estimated_costs_only_with_layer_info`: A mutable reference to a `HashMap` /// that stores estimated layer information based on the key information path. pub(super) fn add_estimation_costs_for_address_balance_update_v0( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { // DataContract_Documents 64 // / \ diff --git a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v1/mod.rs b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v1/mod.rs index 8d3e7339e96..f55ba6de594 100644 --- a/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v1/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimated_costs/for_address_balance_update/v1/mod.rs @@ -3,12 +3,12 @@ use crate::drive::address_funds::estimated_costs::for_address_balance_update::v0 }; use crate::drive::constants::AVERAGE_BALANCE_SIZE; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::{AllItemsWithSumItem, AllSubtrees}; use grovedb::EstimatedSumTrees::SomeSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { /// Adds estimation costs for address balance updates in version 1. @@ -27,7 +27,7 @@ impl Drive { /// leaves; they're `AllSubtrees(...)` with weighted child mixes /// that grovedb's `SomeSumTrees` already covers correctly. pub(super) fn add_estimation_costs_for_address_balance_update_v1( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { // Root level estimation (identical to v0). estimated_costs_only_with_layer_info.insert( diff --git a/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/mod.rs b/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/mod.rs index 675aee02c1c..50cd238c370 100644 --- a/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/mod.rs @@ -4,12 +4,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Removes a balance from a given address in the AddressBalances tree. @@ -33,9 +32,7 @@ impl Drive { &self, address: PlatformAddress, amount_to_remove: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/v0/mod.rs index e0ac17c145b..f16b09ab0b2 100644 --- a/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/remove_balance_from_address/v0/mod.rs @@ -2,13 +2,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use grovedb::{Element, TransactionArg}; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Version 0 implementation of removing balance from an address. @@ -31,9 +30,7 @@ impl Drive { &self, address: PlatformAddress, amount_to_remove: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/address_funds/set_balance_to_address/mod.rs b/packages/rs-drive/src/drive/address_funds/set_balance_to_address/mod.rs index 961b6554264..8510dcc42ec 100644 --- a/packages/rs-drive/src/drive/address_funds/set_balance_to_address/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/set_balance_to_address/mod.rs @@ -4,13 +4,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::prelude::AddressNonce; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Sets a balance for a given address in the AddressBalances tree. @@ -33,9 +31,7 @@ impl Drive { address: PlatformAddress, nonce: AddressNonce, balance: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { @@ -68,9 +64,7 @@ impl Drive { address: PlatformAddress, nonce: AddressNonce, balance: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version diff --git a/packages/rs-drive/src/drive/address_funds/set_balance_to_address/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/set_balance_to_address/v0/mod.rs index 281a859fd62..0dc3881326b 100644 --- a/packages/rs-drive/src/drive/address_funds/set_balance_to_address/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/set_balance_to_address/v0/mod.rs @@ -1,15 +1,14 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::prelude::AddressNonce; use dpp::ProtocolError; -use grovedb::batch::KeyInfoPath; use grovedb::element::SumValue; -use grovedb::{Element, EstimatedLayerInformation}; +use grovedb::Element; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Version 0 implementation of setting a balance for an address. @@ -30,9 +29,7 @@ impl Drive { address: PlatformAddress, nonce: AddressNonce, balance: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { let path = Self::clear_addresses_path(); diff --git a/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/mod.rs b/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/mod.rs index 4465153bfad..7afa4eea054 100644 --- a/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/mod.rs +++ b/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/mod.rs @@ -6,14 +6,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::platform_value::Bytes36; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds operations to a given `outpoint` if it is present in the estimated costs. @@ -30,9 +28,7 @@ impl Drive { &self, outpoint: &Bytes36, asset_lock_value: AssetLockValue, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version diff --git a/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/v0/mod.rs b/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/v0/mod.rs index feae31346bb..8d7a2245b1d 100644 --- a/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/asset_lock/add_asset_lock_outpoint_operations/v0/mod.rs @@ -1,6 +1,7 @@ //! Implements in Drive a function which adds operations to a given `outpoint` if it is present in the estimated costs. use crate::drive::asset_lock::asset_lock_storage_path; +use crate::util::estimated_layers::EstimatedLayers; use crate::drive::Drive; use crate::error::Error; @@ -11,10 +12,7 @@ use dpp::asset_lock::reduced_asset_lock_value::{AssetLockValue, AssetLockValueGe use dpp::platform_value::Bytes36; use dpp::serialization::PlatformSerializable; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::Element::Item; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds operations to a given `outpoint` if it is present in the estimated costs. @@ -32,9 +30,7 @@ impl Drive { &self, outpoint: &Bytes36, asset_lock_value: AssetLockValue, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { let mut drive_operations = vec![]; diff --git a/packages/rs-drive/src/drive/asset_lock/estimation_costs/add_estimation_costs_for_adding_asset_lock/v0/mod.rs b/packages/rs-drive/src/drive/asset_lock/estimation_costs/add_estimation_costs_for_adding_asset_lock/v0/mod.rs index efb0c2c34bb..552082fbc17 100644 --- a/packages/rs-drive/src/drive/asset_lock/estimation_costs/add_estimation_costs_for_adding_asset_lock/v0/mod.rs +++ b/packages/rs-drive/src/drive/asset_lock/estimation_costs/add_estimation_costs_for_adding_asset_lock/v0/mod.rs @@ -1,6 +1,7 @@ //! Implements in Drive a function which adds estimated costs to a hashmap for adding an asset lock (version 0). use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; @@ -9,7 +10,6 @@ use grovedb::{EstimatedLayerInformation, TreeType}; use crate::drive::asset_lock::asset_lock_storage_path; use grovedb::EstimatedSumTrees::SomeSumTrees; -use std::collections::HashMap; impl Drive { /// Add estimated costs to a hashmap for adding an asset lock (version 0). @@ -45,7 +45,7 @@ impl Drive { /// - Each item in this layer has a size of 36, which represents the size of an outpoint. /// pub(crate) fn add_estimation_costs_for_adding_asset_lock_v0( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { // DataContract_Documents 64 // / \ diff --git a/packages/rs-drive/src/drive/asset_lock/estimation_costs/mod.rs b/packages/rs-drive/src/drive/asset_lock/estimation_costs/mod.rs index 6bd560cbc51..011ef7e876e 100644 --- a/packages/rs-drive/src/drive/asset_lock/estimation_costs/mod.rs +++ b/packages/rs-drive/src/drive/asset_lock/estimation_costs/mod.rs @@ -3,24 +3,19 @@ mod add_estimation_costs_for_adding_asset_lock; use crate::drive::Drive; - -use grovedb::batch::KeyInfoPath; - -use grovedb::EstimatedLayerInformation; +use crate::util::estimated_layers::EstimatedLayers; use crate::error::drive::DriveError; use crate::error::Error; use dpp::version::drive_versions::DriveVersion; -use std::collections::HashMap; - impl Drive { /// Add estimated costs to a hashmap for adding an asset lock (version 0). /// /// This function modifies the provided hashmap, `estimated_costs_only_with_layer_info`, /// by inserting two sets of key-value pairs related to the estimation costs for adding an asset lock. pub(crate) fn add_estimation_costs_for_adding_asset_lock( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/mod.rs b/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/mod.rs index 4bb7a7a00f9..a7dc94792ea 100644 --- a/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/mod.rs +++ b/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/mod.rs @@ -4,13 +4,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; - -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Provides the operations needed to add to system credits @@ -32,9 +30,7 @@ impl Drive { pub fn add_to_system_credits_operations( &self, amount: u64, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/v0/mod.rs b/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/v0/mod.rs index 16096317bc7..2544544d9e0 100644 --- a/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/balances/add_to_system_credits_operations/v0/mod.rs @@ -5,14 +5,14 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::batch::QualifiedGroveDbOp; use grovedb::Element::Item; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use integer_encoding::VarInt; -use std::collections::HashMap; impl Drive { /// The operations to add to system credits @@ -20,9 +20,7 @@ impl Drive { pub(super) fn add_to_system_credits_operations_v0( &self, amount: u64, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/mod.rs b/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/mod.rs index 4c4aa70cc92..1d9c7f3995a 100644 --- a/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/mod.rs +++ b/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/mod.rs @@ -4,11 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Returns the operations required to remove from system credits. @@ -33,9 +32,7 @@ impl Drive { pub fn remove_from_system_credits_operations( &self, amount: u64, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/v0/mod.rs b/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/v0/mod.rs index d6cc791db2f..639b8886417 100644 --- a/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/balances/remove_from_system_credits_operations/v0/mod.rs @@ -5,14 +5,14 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::batch::QualifiedGroveDbOp; use grovedb::Element::Item; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use integer_encoding::VarInt; -use std::collections::HashMap; impl Drive { /// We remove from system credits when: @@ -21,9 +21,7 @@ impl Drive { pub(super) fn remove_from_system_credits_operations_v0( &self, amount: u64, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract/mod.rs index 6d167865cc9..6549d80bba7 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract/mod.rs @@ -2,16 +2,15 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use std::borrow::Cow; -use std::collections::HashMap; mod v0; @@ -99,9 +98,7 @@ impl Drive { &self, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, storage_flags: Option>, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract/v0/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract/v0/mod.rs index ec64a40d2df..438fecc8b1e 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract/v0/mod.rs @@ -2,16 +2,15 @@ use crate::drive::contract::serialize_contract_for_storage; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use std::borrow::Cow; -use std::collections::HashMap; impl Drive { /// Applies a contract and returns the fee for applying. @@ -73,9 +72,7 @@ impl Drive { &self, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, storage_flags: Option>, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs index a9e50b56ddc..e4b0577d9c6 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs @@ -2,16 +2,15 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use std::borrow::Cow; -use std::collections::HashMap; mod v0; mod v1; @@ -132,9 +131,7 @@ impl Drive { contract: &DataContract, contract_serialization: Vec, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, storage_flags: Option>, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v0/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v0/mod.rs index 0c916c3ec51..1e3bbc0abf8 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v0/mod.rs @@ -1,4 +1,5 @@ use crate::drive::contract::paths::{contract_keeping_history_root_path, contract_root_path}; +use crate::util::estimated_layers::EstimatedLayers; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -17,10 +18,8 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructureTrusted; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use grovedb::{Element, TransactionArg, TreeType}; use std::borrow::Cow; -use std::collections::HashMap; impl Drive { /// Applies a contract and returns the fee for applying. @@ -39,9 +38,9 @@ impl Drive { ) -> Result { let mut cost_operations = vec![]; let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.apply_contract_with_serialization_operations_v0( contract, @@ -83,9 +82,7 @@ impl Drive { contract: &DataContract, contract_serialization: Vec, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, storage_flags: Option>, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs index fb628db4f5f..d3f4d8bac6f 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs @@ -1,5 +1,6 @@ use crate::drive::contract::estimated_stored_contract_size; use crate::drive::contract::paths::{contract_keeping_history_root_path, contract_root_path}; +use crate::util::estimated_layers::EstimatedLayers; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -21,10 +22,8 @@ use dpp::serialization::{ }; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use grovedb::{Element, TransactionArg, TreeType}; use std::borrow::Cow; -use std::collections::HashMap; impl Drive { /// Applies a contract and returns the fee for applying: generation 0's, with the operations @@ -43,9 +42,9 @@ impl Drive { ) -> Result { let mut cost_operations = vec![]; let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.apply_contract_with_serialization_operations_v1( contract, @@ -97,9 +96,7 @@ impl Drive { contract: &DataContract, contract_serialization: Vec, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, storage_flags: Option>, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/mod.rs b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/mod.rs index 4becca132d6..7756af335ce 100644 --- a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/mod.rs +++ b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/mod.rs @@ -4,12 +4,10 @@ mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::DataContract; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the estimation costs for a contract insertion @@ -25,7 +23,7 @@ impl Drive { /// This function will return an error if the version of the Drive is unknown. pub fn add_estimation_costs_for_contract_insertion( contract: &DataContract, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { match platform_version diff --git a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v0/mod.rs b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v0/mod.rs index e0df3d9e513..a51c79ff7c7 100644 --- a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v0/mod.rs @@ -2,6 +2,7 @@ use crate::drive::constants::{AVERAGE_NUMBER_OF_UPDATES, ESTIMATED_AVERAGE_INDEX use crate::drive::contract::paths::contract_keeping_history_root_path; use crate::drive::document::paths::contract_document_type_path; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::error::Error; @@ -17,14 +18,13 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { /// Adds the estimation costs for a contract insertion #[inline(always)] pub(super) fn add_estimation_costs_for_contract_insertion_v0( contract: &DataContract, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_levels_up_to_contract_document_type_excluded( diff --git a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs index f7ecfd3f6c1..59c4b8c9155 100644 --- a/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/estimation_costs/add_estimation_costs_for_contract_insertion/v1/mod.rs @@ -7,6 +7,7 @@ use crate::drive::document::paths::contract_document_type_path; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; use crate::drive::document::ranked_index_tree_type::property_name_tree_type_and_ranked_axes_for_level; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::error::Error; @@ -23,7 +24,6 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { /// v1 of contract-insertion cost estimation. Differs from v0 by computing @@ -60,7 +60,7 @@ impl Drive { #[inline(always)] pub(super) fn add_estimation_costs_for_contract_insertion_v1( contract: &DataContract, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_levels_up_to_contract_document_type_excluded( @@ -228,6 +228,7 @@ mod tests { //! estimation did — they need to move together. use super::*; use crate::drive::document::paths::contract_document_type_path; + use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::DataContractFactory; use dpp::platform_value::{platform_value, Value}; use dpp::tests::utils::generate_random_identifier_struct; @@ -284,7 +285,7 @@ mod tests { fn non_countable_contract_emits_no_sum_trees_same_as_v0() { let pv = PlatformVersion::latest(); let contract = build_contract(false, false); - let mut layer_info: HashMap = HashMap::new(); + let mut layer_info: EstimatedLayers = EstimatedLayers::new(); crate::drive::Drive::add_estimation_costs_for_contract_insertion_v1( &contract, &mut layer_info, @@ -318,7 +319,7 @@ mod tests { fn documents_countable_contract_emits_some_sum_trees_with_count_weight() { let pv = PlatformVersion::latest(); let contract = build_contract(true, false); - let mut layer_info: HashMap = HashMap::new(); + let mut layer_info: EstimatedLayers = EstimatedLayers::new(); crate::drive::Drive::add_estimation_costs_for_contract_insertion_v1( &contract, &mut layer_info, @@ -377,7 +378,7 @@ mod tests { fn range_countable_index_contract_counts_both_pk_and_index_as_count_children() { let pv = PlatformVersion::latest(); let contract = build_contract(true, true); - let mut layer_info: HashMap = HashMap::new(); + let mut layer_info: EstimatedLayers = EstimatedLayers::new(); crate::drive::Drive::add_estimation_costs_for_contract_insertion_v1( &contract, &mut layer_info, @@ -426,8 +427,8 @@ mod tests { // Non-countable: v0 and v1 must agree (byte-identical NoSumTrees). let plain = build_contract(false, false); - let mut v0_layer: HashMap = HashMap::new(); - let mut v1_layer: HashMap = HashMap::new(); + let mut v0_layer: EstimatedLayers = EstimatedLayers::new(); + let mut v1_layer: EstimatedLayers = EstimatedLayers::new(); crate::drive::Drive::add_estimation_costs_for_contract_insertion_v0( &plain, &mut v0_layer, @@ -453,8 +454,8 @@ mod tests { // Countable: v0 still says NoSumTrees (the bug); v1 says // SomeSumTrees. Diverging on this case is the whole point of v1. let countable = build_contract(true, false); - let mut v0_layer: HashMap = HashMap::new(); - let mut v1_layer: HashMap = HashMap::new(); + let mut v0_layer: EstimatedLayers = EstimatedLayers::new(); + let mut v1_layer: EstimatedLayers = EstimatedLayers::new(); crate::drive::Drive::add_estimation_costs_for_contract_insertion_v0( &countable, &mut v0_layer, diff --git a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs index 11e0447ed7e..5e9ef516f12 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::balances::credits::Credits; use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations that add `amount` credits to one of a contract's fee pots. The pot is @@ -37,9 +36,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, amount: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs index 9f6ce45ace9..e8078a02654 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs @@ -10,14 +10,14 @@ use crate::error::identity::IdentityError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use dpp::balances::credits::{Credits, MAX_CREDITS}; use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::batch::QualifiedGroveDbOp; +use grovedb::{Element, TransactionArg}; impl Drive { #[inline(always)] @@ -26,9 +26,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, amount: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs index d1098036e2d..b241abe258d 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::balances::credits::Credits; use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations that take `amount` credits out of one of a contract's fee pots. @@ -37,9 +36,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, amount: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs index e4266138df1..1e21da02b2a 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs @@ -4,14 +4,14 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use dpp::balances::credits::Credits; use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::batch::QualifiedGroveDbOp; +use grovedb::{Element, TransactionArg}; impl Drive { #[inline(always)] @@ -20,9 +20,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, amount: Credits, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { 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..05708b980aa 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 @@ -3,11 +3,9 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the layer information of a write to one of a contract's fee pots. @@ -24,7 +22,7 @@ impl Drive { /// * `Err(Error)` when the version is unknown. pub(crate) fn add_estimation_costs_for_contract_fee_pot_update( pot: ContractFeePot, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version 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 354b62b2d8d..51ffd6f901f 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 @@ -2,6 +2,7 @@ use crate::drive::constants::AVERAGE_BALANCE_SIZE; use crate::drive::contract::paths::contract_fee_pots_path_vec; use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::document_type::action_fees::ContractFeePot; use grovedb::batch::KeyInfoPath; @@ -10,23 +11,20 @@ use grovedb::EstimatedLayerInformation; use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; use grovedb::EstimatedSumTrees::{AllSumTrees, SomeSumTrees}; use grovedb::TreeType; -use std::collections::HashMap; impl Drive { #[inline(always)] pub(super) fn add_estimation_costs_for_contract_fee_pot_update_v0( pot: ContractFeePot, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { - // 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. 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 keeps the costliest of its description and - // the document's. - estimated_costs_only_with_layer_info - .entry(KeyInfoPath::from_known_path([])) - .or_insert(EstimatedLayerInformation { + // The root layer is left alone when the estimate already describes it: a document + // operation of the same batch describes it as well as this does. An estimate keeping + // every description (`apply_drive_operations` 2, protocol version 15) keeps this one + // too, and describes the root by the costliest of it and the document's. + estimated_costs_only_with_layer_info.insert_if_absent( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { tree_type: TreeType::NormalTree, // The prefunded specialized balances tree is on the 3rd level estimated_layer_count: EstimatedLevel(3, false), @@ -45,7 +43,8 @@ impl Drive { }, None, ), - }); + }, + ); // Three sum trees: the voting balances on top, the two kinds of fee pots below. estimated_costs_only_with_layer_info.insert( diff --git a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/mod.rs index 23bc5849889..d5373f4916b 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/mod.rs @@ -4,12 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::document_type::action_fees::{ContractFeePot, ContractFeePotLastClaim}; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// The operations that record the claim that paid out one of a contract's fee pots: its @@ -33,9 +31,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, last_claim: &ContractFeePotLastClaim, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version diff --git a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/v0/mod.rs index b8e4a4c9d21..4270278b711 100644 --- a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim/v0/mod.rs @@ -4,12 +4,12 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::document_type::action_fees::{ContractFeePot, ContractFeePotLastClaim}; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; -use grovedb::{Element, EstimatedLayerInformation}; -use std::collections::HashMap; +use grovedb::batch::QualifiedGroveDbOp; +use grovedb::Element; impl Drive { #[inline(always)] @@ -18,9 +18,7 @@ impl Drive { contract_id: Identifier, pot: ContractFeePot, last_claim: &ContractFeePotLastClaim, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { diff --git a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/mod.rs index da1672528e8..ea02c156409 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/mod.rs @@ -5,14 +5,12 @@ use crate::drive::Drive; use crate::drive::LowLevelDriveOperation; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; - -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Adds a contract to storage. @@ -22,9 +20,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, insert_operations: &mut Vec, is_first_insert: bool, transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v0/mod.rs index 19df05073eb..3b585eb7bea 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v0/mod.rs @@ -4,6 +4,7 @@ use crate::drive::LowLevelDriveOperation; use crate::error::drive::DriveError; use crate::error::Error; use crate::util::common::encode::encode_u64; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::{BatchInsertTreeApplyType, DirectQueryType}; use crate::util::object_size_info::DriveKeyInfo::KeyRef; use crate::util::object_size_info::PathKeyElementInfo::{ @@ -19,8 +20,7 @@ use dpp::version::drive_versions::DriveVersion; use grovedb::batch::key_info::KeyInfo; use grovedb::batch::KeyInfoPath; use grovedb::reference_path::ReferencePathType::SiblingReference; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { /// Adds a contract to storage. @@ -31,9 +31,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, insert_operations: &mut Vec, is_first_insert: bool, transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v1/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v1/mod.rs index 2306e4c01a2..5e86e9bdef7 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_contract_to_storage/v1/mod.rs @@ -5,6 +5,7 @@ use crate::drive::contract::version_item::encode_contract_version; use crate::drive::Drive; use crate::drive::LowLevelDriveOperation; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use crate::util::object_size_info::DriveKeyInfo; use crate::util::object_size_info::PathKeyElementInfo::{ @@ -17,8 +18,7 @@ use dpp::data_contract::DataContract; use dpp::version::drive_versions::DriveVersion; use grovedb::batch::key_info::KeyInfo; use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Adds a contract to storage as v0 does, then writes the contract's version number as a @@ -37,9 +37,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, insert_operations: &mut Vec, is_first_insert: bool, transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/contract/insert/add_description/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_description/mod.rs index e6c4b2999a2..3f7130532d5 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_description/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_description/mod.rs @@ -2,14 +2,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; use dpp::prelude::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; mod v0; @@ -101,9 +100,7 @@ impl Drive { description: &String, short_only: bool, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs index e3caf1b676f..3e81cb3da2a 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs @@ -1,6 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use dpp::block::block_info::BlockInfo; @@ -9,10 +10,9 @@ use dpp::data_contracts::keyword_search_contract; use dpp::document::{Document, DocumentV0}; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; impl Drive { /// Creates the documents in the Keyword Search contract for the contract description and @@ -66,9 +66,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.add_new_contract_description_operations_v0( @@ -100,9 +100,7 @@ impl Drive { description: &String, short_only: bool, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/mod.rs index 0f89e9043c0..89c37a64885 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/mod.rs @@ -2,14 +2,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; use dpp::prelude::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; mod v0; @@ -84,9 +83,7 @@ impl Drive { owner_id: Identifier, keywords: &[String], block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs index 4838c300466..824407e8907 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs @@ -1,6 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use dpp::block::block_info::BlockInfo; @@ -9,10 +10,9 @@ use dpp::data_contracts::keyword_search_contract; use dpp::document::{Document, DocumentV0}; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; impl Drive { /// Creates the documents in the Keyword Search contract for the contract keywords and @@ -63,9 +63,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.add_new_contract_keywords_operations( @@ -95,9 +95,7 @@ impl Drive { owner_id: Identifier, keywords: &[String], block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/mod.rs index 5bc1d9b3133..40449191d09 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/mod.rs @@ -6,14 +6,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Inserts a contract into the drive. @@ -91,9 +90,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs index 5008b3b48df..0cfa3b3533c 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::contract::paths; use crate::drive::contract::serialize_contract_for_storage; +use crate::util::estimated_layers::EstimatedLayers; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; use crate::drive::document::ranked_index_tree_type::property_name_tree_type_and_ranked_axes_for_level; @@ -20,9 +21,7 @@ use crate::drive::votes::paths::{ CONTESTED_DOCUMENT_INDEXES_TREE_KEY, CONTESTED_DOCUMENT_STORAGE_TREE_KEY, }; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { /// Insert a contract. @@ -89,9 +88,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.insert_contract_operations_v0( contract_element, @@ -118,9 +117,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { @@ -143,9 +140,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { let mut batch_operations: Vec = vec![]; diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_array_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_array_index_e2e_tests.rs index a1f16948ce3..cb6c26ba42f 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_array_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_array_index_e2e_tests.rs @@ -29,6 +29,7 @@ use crate::query::{ key_array_element_clauses, CountMode, DocumentCountRequest, DocumentCountResponse, DriveDocumentCountQuery, DriveDocumentQuery, ResolvedTimeRange, WhereClause, WhereOperator, }; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -45,7 +46,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::platform_value::{platform_value, Identifier, Value}; use dpp::prelude::DataContract; use grovedb::{Element, GroveDb, TransactionArg}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; const POST_A: [u8; 32] = [0xA1; 32]; const POST_B: [u8; 32] = [0xB2; 32]; @@ -992,7 +993,7 @@ fn should_bill_the_commitment_hash_of_every_entry() { let transaction = drive.grove.start_transaction(); let hashes = |estimate: bool| -> Vec { - let mut estimated_costs_only_with_layer_info = estimate.then(HashMap::new); + let mut estimated_costs_only_with_layer_info = estimate.then(EstimatedLayers::new); drive .add_document_for_contract_operations( DocumentAndContractInfo { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs index 8fef5fe135a..635d32253c9 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs @@ -30,6 +30,7 @@ //! sweep clean. use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; @@ -535,7 +536,7 @@ fn estimated_fees_upper_bound_actual_fees() { fn delete_estimate_prices_one_probe_read_per_index_entry() { use crate::fees::op::LowLevelDriveOperation; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; - use std::collections::HashMap; + let (drive, contract) = setup_likes(); let like = build_like(&contract, "dash", POST_A, OWNER_1, 1); insert_like(&drive, &contract, &like, true).expect("insert"); @@ -556,7 +557,7 @@ fn delete_estimate_prices_one_probe_read_per_index_entry() { assert!(probe_paths > 0); let tx = drive.grove.start_transaction(); let priced_reads = |estimate: bool| -> usize { - let mut estimated_costs_only_with_layer_info = estimate.then(HashMap::new); + let mut estimated_costs_only_with_layer_info = estimate.then(EstimatedLayers::new); drive .delete_index_only_document_for_contract_operations( like.clone(), diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs index c59217767d2..0c25532ad1d 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs @@ -1,5 +1,6 @@ use crate::drive::contract::serialize_contract_for_storage; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::error::Error; @@ -26,10 +27,8 @@ use dpp::tokens::contract_info::TokenContractInfo; use dpp::tokens::status::TokenStatus; use dpp::version::PlatformVersion; use dpp::ProtocolError; -use grovedb::batch::KeyInfoPath; use grovedb::Element::SumItem; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Insert a contract. @@ -109,9 +108,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.insert_contract_operations_v1( contract_element, @@ -140,9 +139,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -167,9 +164,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs index 85ca6e0d7ea..f7044c7a3cb 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs @@ -2,6 +2,7 @@ use crate::drive::Drive; use crate::error::contract::DataContractError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -15,9 +16,7 @@ use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::tokens::calculate_token_id; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Generation 2 (protocol version 14): generation 1, and a contract whose config declares @@ -38,9 +37,9 @@ impl Drive { Self::contract_element_for_insert_v1(contract, &block_info, platform_version)?; let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.insert_contract_operations_v2( contract_element, @@ -76,9 +75,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, drive_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -103,9 +100,7 @@ impl Drive { contract_element: Element, contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/mod.rs index 18cc6d0e4c3..7c3a867bbab 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/mod.rs @@ -4,14 +4,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::config::moderation::ContractModerationReason; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Puts `identity_id` on the banlist of `contract_id` for `reason`, paid by `moderator_id`, @@ -98,9 +97,7 @@ impl Drive { reason: &ContractModerationReason, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/v0/mod.rs index 93f32f77714..64d089d6358 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_ban/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; @@ -10,10 +11,8 @@ use dpp::data_contract::config::moderation::{ContractModerationList, ContractMod use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::Element; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { #[inline(always)] @@ -30,9 +29,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.add_contract_ban_operations_v0( @@ -76,9 +75,7 @@ impl Drive { reason: &ContractModerationReason, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs index f3224293985..0df540b96e1 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::config::moderation::ContractDocumentRemoval; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations writing the record of a moderator's deletion of a document: the record @@ -52,9 +51,7 @@ impl Drive { estimated_kept_fields_size: u32, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs index c4cf1c7d5e4..bb282f5b5d8 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs @@ -4,16 +4,15 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::config::moderation::ContractDocumentRemoval; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::Element; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// A record is the document owner's id, the moderator's id, the removal time, the hash of @@ -48,9 +47,7 @@ impl Drive { estimated_kept_fields_size: u32, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs index 27edb72855a..595ad598d34 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs @@ -4,15 +4,14 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::config::moderation::ContractModerationReason; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::identity::TimestampMillis; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Puts `identity_id` on the suspension list of `contract_id`, paid by `moderator_id`, whose @@ -111,9 +110,7 @@ impl Drive { replaces_existing: bool, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v0/mod.rs index e4a41d85867..8e6c3623bbd 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; @@ -11,10 +12,8 @@ use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::identity::TimestampMillis; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::Element; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { #[inline(always)] @@ -33,9 +32,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.add_contract_suspension_operations_v0( @@ -92,9 +91,7 @@ impl Drive { replaces_existing: bool, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/mod.rs index 04548b8a12f..57f29819b0c 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/mod.rs @@ -5,12 +5,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations writing one member's proposal or approval of one of a contract's team @@ -56,9 +55,7 @@ impl Drive { signer_id: Identifier, write: &ContractTeamActionWrite, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/v0/mod.rs index 12d9dc9b2a6..02783bf525f 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_team_action_signature/v0/mod.rs @@ -10,6 +10,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::{ BatchDeleteApplyType, BatchInsertTreeApplyType, BatchMoveApplyType, }; @@ -21,10 +22,8 @@ use dpp::group::group_action_status::GroupActionStatus; use dpp::identifier::Identifier; use dpp::version::drive_versions::DriveVersion; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::MaybeTree; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { #[allow(clippy::too_many_arguments)] @@ -35,9 +34,7 @@ impl Drive { signer_id: Identifier, write: &ContractTeamActionWrite, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs index 510e247286e..75a940dc5d1 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs @@ -4,14 +4,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::config::moderation::ContractWarning; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Writes the warning list entry of `identity_id` on `contract_id` as `warnings`: the @@ -107,9 +106,7 @@ impl Drive { replaces_existing: bool, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs index cfc9789df68..0d09936a18d 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs @@ -4,6 +4,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; @@ -11,10 +12,8 @@ use dpp::data_contract::config::moderation::{ContractModerationList, ContractWar use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::Element; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { #[inline(always)] @@ -32,9 +31,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.add_contract_warning_operations_v0( @@ -90,9 +89,7 @@ impl Drive { replaces_existing: bool, moderator_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs index 2b731f8b120..cc70ee14865 100644 --- a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs @@ -3,11 +3,9 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::config::moderation::ContractModerationList; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for creating a contract's moderation list trees: @@ -25,7 +23,7 @@ impl Drive { /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_moderation_trees( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -64,7 +62,7 @@ impl Drive { pub(crate) fn add_estimation_costs_for_contract_moderation_entry( contract_id: [u8; 32], list: ContractModerationList, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -103,7 +101,7 @@ impl Drive { /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_moderation_action_counts( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -140,7 +138,7 @@ impl Drive { /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_document_removal_trees( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -183,7 +181,7 @@ impl Drive { contract_id: [u8; 32], document_type_name: &str, estimated_kept_fields_size: u32, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -223,7 +221,7 @@ impl Drive { /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_team_action_trees( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version @@ -265,7 +263,7 @@ impl Drive { contract_id: [u8; 32], action_id: [u8; 32], closes: bool, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs index 43f80dfeb8a..4dc423dfa01 100644 --- a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs @@ -14,6 +14,7 @@ use crate::drive::contract::paths::{ }; use crate::drive::Drive; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::config::moderation::ContractModerationList; @@ -24,12 +25,11 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel, Potentia use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::{AllSumTrees, NoSumTrees}; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { pub(super) fn add_estimation_costs_for_contract_moderation_trees_v0( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_levels_up_to_contract( @@ -49,7 +49,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_contract_moderation_entry_v0( contract_id: [u8; 32], list: ContractModerationList, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_moderation_trees_v0( @@ -80,7 +80,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_contract_moderation_action_counts_v0( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_moderation_trees_v0( @@ -111,7 +111,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_contract_document_removal_trees_v0( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_moderation_trees_v0( @@ -144,7 +144,7 @@ impl Drive { contract_id: [u8; 32], document_type_name: &str, estimated_kept_fields_size: u32, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_document_removal_trees_v0( @@ -178,7 +178,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_contract_team_action_trees_v0( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_moderation_trees_v0( @@ -208,7 +208,7 @@ impl Drive { contract_id: [u8; 32], action_id: [u8; 32], closes: bool, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { Self::add_estimation_costs_for_contract_team_action_trees_v0( diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs index 20a4e48295b..1729f78b1db 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs @@ -4,11 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations creating the trees that hold the records of the documents a contract's @@ -45,9 +44,7 @@ impl Drive { with_root: bool, document_type_names: &[&str], storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/v0/mod.rs index f39c2815113..d2904012a73 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/v0/mod.rs @@ -4,12 +4,11 @@ use crate::drive::contract::paths::{ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DriveKeyInfo; use crate::util::storage_flags::StorageFlags; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { #[inline(always)] @@ -20,9 +19,7 @@ impl Drive { with_root: bool, document_type_names: &[&str], storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs index 9b666a5bedd..e9fef4afae2 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs @@ -4,12 +4,11 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::data_contract::config::moderation::ContractModerationConfig; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Adds the operations that create the moderation list trees a contract's config declares @@ -40,9 +39,7 @@ impl Drive { contract_id: [u8; 32], moderation: &ContractModerationConfig, storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/v0/mod.rs index f78e6915baf..5594d64faba 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/v0/mod.rs @@ -4,13 +4,12 @@ use crate::drive::contract::paths::{ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DriveKeyInfo; use crate::util::storage_flags::StorageFlags; use dpp::data_contract::config::moderation::ContractModerationConfig; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { #[inline(always)] @@ -20,9 +19,7 @@ impl Drive { contract_id: [u8; 32], moderation: &ContractModerationConfig, storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/mod.rs index 612ada95f83..01e515148df 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/mod.rs @@ -4,11 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations creating the trees of the actions a contract's seated moderation team @@ -39,9 +38,7 @@ impl Drive { &self, contract_id: [u8; 32], storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/v0/mod.rs index 43f846114e1..38203c055a2 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_team_action_trees/v0/mod.rs @@ -5,12 +5,11 @@ use crate::drive::contract::paths::{ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DriveKeyInfo; use crate::util::storage_flags::StorageFlags; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Unconditional, like the list trees: only a contract's insertion creates them, and it @@ -20,9 +19,7 @@ impl Drive { &self, contract_id: [u8; 32], storage_flags: Option<&StorageFlags>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, _transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs index 635e59da960..c51755740ea 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Takes `identity_id` off the banlist of `contract_id`. The storage refund goes to the @@ -87,9 +86,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v0/mod.rs index e7589984294..959370b7a2c 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteApplyType; use crate::util::storage_flags::StorageFlags; use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; @@ -11,10 +12,8 @@ use dpp::data_contract::config::moderation::ContractModerationList; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use grovedb::{MaybeTree, TreeType}; -use std::collections::HashMap; impl Drive { #[inline(always)] @@ -29,9 +28,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.remove_contract_ban_operations_v0( @@ -71,9 +70,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, _block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/mod.rs index ca74fe796bc..b3d935d9942 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/mod.rs @@ -4,11 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// The operations that delete the moderation action counts of `identity_ids` on the @@ -32,9 +31,7 @@ impl Drive { &self, contract_id: Identifier, identity_ids: &[Identifier], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/v0/mod.rs index d1c0926214c..1a765b3b6ff 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_moderation_action_counts/v0/mod.rs @@ -3,14 +3,13 @@ use crate::drive::contract::paths::contract_moderation_action_counts_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteApplyType; use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use grovedb::{MaybeTree, TreeType}; -use std::collections::HashMap; impl Drive { #[inline(always)] @@ -18,9 +17,7 @@ impl Drive { &self, contract_id: Identifier, identity_ids: &[Identifier], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs index b9de9495b19..bc6c235fb2c 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Takes `identity_id` off the suspension list of `contract_id`. The storage refund goes to the @@ -87,9 +86,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v0/mod.rs index a8c5bf9a6b1..b7b6aa7606d 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteApplyType; use crate::util::storage_flags::StorageFlags; use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; @@ -11,10 +12,8 @@ use dpp::data_contract::config::moderation::ContractModerationList; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use grovedb::{MaybeTree, TreeType}; -use std::collections::HashMap; impl Drive { #[inline(always)] @@ -29,9 +28,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.remove_contract_suspension_operations_v0( @@ -71,9 +70,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, _block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs index 6886bf14d62..60d6b649d6f 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Takes `identity_id` off the warning list of `contract_id`: every warning it carries @@ -87,9 +86,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs index b399cafa834..d3dac67b6f9 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteApplyType; use crate::util::storage_flags::StorageFlags; use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; @@ -11,10 +12,8 @@ use dpp::data_contract::config::moderation::ContractModerationList; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use grovedb::{MaybeTree, TreeType}; -use std::collections::HashMap; impl Drive { #[inline(always)] @@ -29,9 +28,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.remove_contract_warnings_operations_v0( @@ -71,9 +70,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, _block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/mod.rs b/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/mod.rs index f366d07b182..52cac0f3c37 100644 --- a/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/mod.rs @@ -4,11 +4,9 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// The operations that write `identity_id`'s count of moderation actions on the elected @@ -36,9 +34,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, count: u32, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { match platform_version diff --git a/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/v0/mod.rs index aa6dee30372..16c1220ece8 100644 --- a/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/set_contract_moderation_action_count/v0/mod.rs @@ -4,11 +4,11 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; -use grovedb::{Element, EstimatedLayerInformation}; -use std::collections::HashMap; +use grovedb::batch::QualifiedGroveDbOp; +use grovedb::Element; impl Drive { #[inline(always)] @@ -17,9 +17,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, count: u32, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result, Error> { if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { diff --git a/packages/rs-drive/src/drive/contract/other_tree.rs b/packages/rs-drive/src/drive/contract/other_tree.rs index d6bc518f80a..8d021cef608 100644 --- a/packages/rs-drive/src/drive/contract/other_tree.rs +++ b/packages/rs-drive/src/drive/contract/other_tree.rs @@ -5,6 +5,7 @@ use crate::drive::contract::paths::{contract_other_path, contract_root_path}; use crate::drive::contract::version_item::CONTRACT_VERSION_ITEM_SIZE; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::EstimatedLevel; @@ -12,32 +13,29 @@ use grovedb::EstimatedLayerInformation; use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::TreeType; -use std::collections::HashMap; impl Drive { /// Adds the layer information of a contract's other tree (`[64, id, 2]`: the version item /// 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. 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, and the batch keeps the costliest of this - /// description and the other operation's. + /// better than this does. An estimate keeping every description (`apply_drive_operations` + /// 2, protocol version 15) keeps this one too, and describes the root subtree by the + /// costliest of it and the others written for it. pub(crate) fn add_estimation_costs_for_contract_other_tree( contract_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { let flags_size = Some(StorageFlags::approximate_size(true, None)); - estimated_costs_only_with_layer_info - .entry(KeyInfoPath::from_known_path(contract_root_path( - &contract_id, - ))) - .or_insert(EstimatedLayerInformation { + estimated_costs_only_with_layer_info.insert_if_absent( + KeyInfoPath::from_known_path(contract_root_path(&contract_id)), + EstimatedLayerInformation { tree_type: TreeType::NormalTree, estimated_layer_count: EstimatedLevel(1, false), estimated_layer_sizes: AllSubtrees(1, NoSumTrees, flags_size), - }); + }, + ); estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_path(contract_other_path(&contract_id)), diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs index 5b838566042..759a6160ecb 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs @@ -6,15 +6,14 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Updates a data contract. @@ -206,9 +205,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs index 43230085c5a..80de0f1e3a5 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v0/mod.rs @@ -5,6 +5,7 @@ use crate::drive::{contract_documents_path, Drive}; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::util::object_size_info::DriveKeyInfo::KeyRef; use crate::util::object_size_info::PathKeyInfo::PathFixedSizeKeyRef; @@ -18,9 +19,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { /// Updates a data contract. @@ -154,8 +153,7 @@ impl Drive { drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { - let mut estimated_costs_only_with_layer_info = - None::>; + let mut estimated_costs_only_with_layer_info = None::; let batch_operations = self.update_contract_operations_v0( contract_element, contract, @@ -183,9 +181,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, @@ -211,9 +207,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs index 45a5c591ba5..9dcb2a5d8e6 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs @@ -3,6 +3,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -15,9 +16,7 @@ use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Updates a data contract. @@ -151,8 +150,7 @@ impl Drive { drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { - let mut estimated_costs_only_with_layer_info = - None::>; + let mut estimated_costs_only_with_layer_info = None::; let batch_operations = self.update_contract_operations_v1( contract_element, contract, @@ -180,9 +178,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, @@ -208,9 +204,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs index bac001e5ed2..ecf598277c1 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs @@ -3,6 +3,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -23,9 +24,7 @@ use dpp::data_contract::associated_token::token_distribution_rules::accessors::v use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; use dpp::ProtocolError; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg}; impl Drive { /// Updates a data contract. @@ -160,8 +159,7 @@ impl Drive { drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { - let mut estimated_costs_only_with_layer_info = - None::>; + let mut estimated_costs_only_with_layer_info = None::; let batch_operations = self.update_contract_operations_v2( contract_element, contract, @@ -189,9 +187,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, @@ -235,9 +231,7 @@ impl Drive { contract: &DataContract, original_contract: &DataContract, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_description/mod.rs b/packages/rs-drive/src/drive/contract/update/update_description/mod.rs index b0f8d2af290..f7323cf11e6 100644 --- a/packages/rs-drive/src/drive/contract/update/update_description/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_description/mod.rs @@ -2,14 +2,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; use dpp::prelude::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; mod v0; @@ -99,9 +98,7 @@ impl Drive { owner_id: Identifier, description: &String, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_description/v0/mod.rs b/packages/rs-drive/src/drive/contract/update/update_description/v0/mod.rs index fbcb23b00d3..ec5a2058bc6 100644 --- a/packages/rs-drive/src/drive/contract/update/update_description/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_description/v0/mod.rs @@ -4,6 +4,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::query::{DriveDocumentQuery, WhereClause, WhereOperator}; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfo, OwnedDocumentInfo}; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -11,10 +12,8 @@ use dpp::document::DocumentV0Setters; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::platform_value::Value; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::HashMap; impl Drive { /// Updates the documents in the Keyword Search contract for the contract @@ -67,9 +66,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.update_contract_description_operations( @@ -100,9 +99,7 @@ impl Drive { owner_id: Identifier, description: &String, block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_keywords/mod.rs b/packages/rs-drive/src/drive/contract/update/update_keywords/mod.rs index 73d0b6b07c1..5bd82a7a907 100644 --- a/packages/rs-drive/src/drive/contract/update/update_keywords/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_keywords/mod.rs @@ -2,14 +2,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; use dpp::prelude::Identifier; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; mod v0; @@ -99,9 +98,7 @@ impl Drive { owner_id: Identifier, keywords: &[String], block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract/update/update_keywords/v0/mod.rs b/packages/rs-drive/src/drive/contract/update/update_keywords/v0/mod.rs index 08f3168acd6..21fd2d56c17 100644 --- a/packages/rs-drive/src/drive/contract/update/update_keywords/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_keywords/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::query::{DriveDocumentQuery, WhereClause, WhereOperator}; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::DocumentV0Getters; @@ -10,10 +11,9 @@ use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; use dpp::platform_value::Value; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; impl Drive { /// Updates the documents in the Keyword Search contract for the contract @@ -66,9 +66,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result<(), Error> { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.update_contract_keywords_operations( @@ -99,9 +99,7 @@ impl Drive { owner_id: Identifier, keywords: &[String], block_info: &BlockInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs index 7aecbfd356e..c92efe18eaf 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs @@ -3,10 +3,8 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for registering a contract group: the root tree, @@ -24,7 +22,7 @@ impl Drive { /// * `Err(Error)` when the method version is unknown. pub(crate) fn add_estimation_costs_for_insert_contract_group( contract_group_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/v0/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/v0/mod.rs index 681d133c44b..18199914491 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/v0/mod.rs @@ -3,19 +3,19 @@ use crate::drive::contract_groups::paths::{ contract_group_path, contract_groups_groups_path, contract_groups_root_path, }; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { /// Adds the layer information common to every write under the `ContractGroups` root tree: /// the GroveDB root and the root tree with its two subtrees. pub(in crate::drive::contract_groups) fn add_estimation_costs_for_contract_groups_root_layers( - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { // The GroveDB root holds every root tree; ContractGroups is one normal tree among them. estimated_costs_only_with_layer_info.insert( @@ -40,7 +40,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_insert_contract_group_v0( contract_group_id: [u8; 32], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { Self::add_estimation_costs_for_contract_groups_root_layers( estimated_costs_only_with_layer_info, diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs index 96f7d4ccb94..8a9d625ffb7 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs @@ -3,11 +3,9 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::contract_group::ContractGroupMembership; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for the forward and backwards entries of a new @@ -28,7 +26,7 @@ impl Drive { pub(crate) fn add_estimation_costs_for_insert_contract_group_memberships( contract_id: [u8; 32], memberships: &[ContractGroupMembership], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/v0/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/v0/mod.rs index 9cfa21ad789..b7a184367eb 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/v0/mod.rs @@ -10,6 +10,7 @@ use crate::drive::contract_groups::paths::{ contract_memberships_tokens_path, }; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::contract_group::{ContractGroupMember, ContractGroupMembership}; use grovedb::batch::KeyInfoPath; @@ -17,13 +18,12 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees, Mix}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { pub(super) fn add_estimation_costs_for_insert_contract_group_memberships_v0( contract_id: [u8; 32], memberships: &[ContractGroupMembership], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { Self::add_estimation_costs_for_contract_groups_root_layers( estimated_costs_only_with_layer_info, diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs index e44bca8785d..30ca0220ff6 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs @@ -4,14 +4,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::contract_group::ContractGroupInfo; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Registers a contract group: its tree under `Groups`, the info item and the three empty @@ -86,9 +85,7 @@ impl Drive { &self, contract_group_id: Identifier, info: &ContractGroupInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/v0/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/v0/mod.rs index df2af7b14f6..079b6a061fd 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/v0/mod.rs @@ -6,6 +6,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::util::object_size_info::DriveKeyInfo; use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; @@ -16,9 +17,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::serialization::PlatformSerializable; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { pub(super) fn insert_contract_group_v0( @@ -31,9 +30,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.insert_contract_group_operations_v0( @@ -67,9 +66,7 @@ impl Drive { &self, contract_group_id: Identifier, info: &ContractGroupInfo, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs index 45cf333fbcd..26e7e8f5344 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs @@ -4,14 +4,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::contract_group::ContractGroupMembership; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Records the contract group memberships of a newly created contract: the forward entries @@ -90,9 +89,7 @@ impl Drive { &self, contract_id: Identifier, memberships: &[ContractGroupMembership], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/v0/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/v0/mod.rs index 02769621dcb..0f921b94dc3 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/v0/mod.rs @@ -13,6 +13,7 @@ use crate::drive::contract_groups::paths::{ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::util::object_size_info::PathKeyElementInfo::PathKeyElement; use crate::util::object_size_info::PathKeyInfo; @@ -21,10 +22,9 @@ use dpp::contract_group::{ContractGroupMember, ContractGroupMembership}; use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; use grovedb::reference_path::ReferencePathType; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::{HashMap, HashSet}; +use grovedb::{Element, TransactionArg, TreeType}; +use std::collections::HashSet; /// A backwards reference from a member contract's index to the forward entry `forward_path` /// (given without the root tree key) at `forward_key`. One hop resolves it. @@ -51,9 +51,9 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let batch_operations = self.insert_contract_group_memberships_operations_v0( @@ -120,9 +120,7 @@ impl Drive { &self, contract_id: Identifier, memberships: &[ContractGroupMembership], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { diff --git a/packages/rs-drive/src/drive/contract_groups/tests.rs b/packages/rs-drive/src/drive/contract_groups/tests.rs index 7f879be9e72..ffda0c33545 100644 --- a/packages/rs-drive/src/drive/contract_groups/tests.rs +++ b/packages/rs-drive/src/drive/contract_groups/tests.rs @@ -10,6 +10,7 @@ use crate::drive::identity::key::fetch::{ use crate::drive::{Drive, RootTree}; use crate::error::identity::IdentityError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::DirectQueryType; use crate::util::test_helpers::setup::{setup_drive, setup_drive_with_initial_state_structure}; use dpp::block::block_info::BlockInfo; @@ -28,7 +29,7 @@ use dpp::version::PlatformVersion; use grovedb_path::SubtreePath; use rand::rngs::StdRng; use rand::SeedableRng; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; fn identity(seed: u8) -> Identifier { Identifier::from([seed; 32]) @@ -401,7 +402,7 @@ fn should_build_the_same_operations_for_estimation_and_for_apply() { .insert_contract_group_operations( group_id, &info, - &mut Some(HashMap::new()), + &mut Some(EstimatedLayers::new()), None, platform_version, ) @@ -419,7 +420,7 @@ fn should_build_the_same_operations_for_estimation_and_for_apply() { .insert_contract_group_memberships_operations( contract_id, &memberships, - &mut Some(HashMap::new()), + &mut Some(EstimatedLayers::new()), None, platform_version, ) diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v0/mod.rs index b82d4b25c51..24b7a7d5f70 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v0/mod.rs @@ -1,6 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::fee::fee_result::FeeResult; @@ -8,9 +9,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Deletes a document and returns the associated fee. @@ -29,9 +28,9 @@ impl Drive { ) -> Result { let mut drive_operations: Vec = vec![]; let estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; self.delete_document_for_contract_apply_and_add_to_operations( document_id, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/mod.rs index e1a451d52a1..65165063b9f 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::DataContract; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Deletes a document. @@ -34,9 +33,7 @@ impl Drive { document_id: Identifier, contract: &DataContract, document_type_name: &str, - estimated_costs_only_with_layer_info: Option< - HashMap, - >, + estimated_costs_only_with_layer_info: Option, block_time_ms: u64, transaction: TransactionArg, drive_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/v0/mod.rs index 2a65843e041..0dd3c9b5461 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_apply_and_add_to_operations/v0/mod.rs @@ -1,13 +1,12 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::DataContract; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Deletes a document. @@ -18,9 +17,7 @@ impl Drive { document_id: Identifier, contract: &DataContract, document_type_name: &str, - mut estimated_costs_only_with_layer_info: Option< - HashMap, - >, + mut estimated_costs_only_with_layer_info: Option, block_time_ms: u64, transaction: TransactionArg, drive_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v0/mod.rs index 50e415bbb06..335441b6279 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v0/mod.rs @@ -1,8 +1,6 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; -use grovedb::{EstimatedLayerInformation, TransactionArg}; - -use std::collections::HashMap; +use grovedb::TransactionArg; use crate::drive::Drive; use crate::error::document::DocumentError; @@ -34,9 +32,9 @@ impl Drive { ) -> Result { let mut drive_operations: Vec = vec![]; let estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; let contract_fetch_info = self diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/mod.rs index 10d1297bdf0..2b81602ce24 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::epoch::Epoch; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Prepares the operations for deleting a document. @@ -37,9 +36,7 @@ impl Drive { document_type_name: &str, epoch: &Epoch, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/v0/mod.rs index 1349e7e07a6..1fc0cfb0109 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id_with_named_type_operations/v0/mod.rs @@ -1,8 +1,6 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; -use grovedb::{EstimatedLayerInformation, TransactionArg}; - -use std::collections::HashMap; +use grovedb::TransactionArg; use crate::drive::Drive; use crate::error::document::DocumentError; @@ -27,9 +25,7 @@ impl Drive { document_type_name: &str, epoch: &Epoch, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/mod.rs index f69baabeb6a..a304905e2b8 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/mod.rs @@ -4,6 +4,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use dpp::data_contract::document_type::DocumentTypeRef; @@ -12,9 +13,7 @@ use dpp::document::Document; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Prepares the operations for deleting a document. @@ -38,9 +37,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -75,9 +72,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -129,9 +124,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -175,9 +168,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -215,6 +206,7 @@ mod tests { use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; + use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -229,17 +221,12 @@ mod tests { use dpp::fee::fee_result::FeeResult; use dpp::tests::json_document::{json_document_to_contract, json_document_to_document}; use dpp::version::PlatformVersion; - use grovedb::batch::KeyInfoPath; - use grovedb::EstimatedLayerInformation; + use std::borrow::Cow; - use std::collections::HashMap; /// What a delete gives: its operations and, when it only estimates costs, the layer /// information it estimated - type DeleteOutcome = ( - Vec, - Option>, - ); + type DeleteOutcome = (Vec, Option); /// Deleting a stored `person` under `platform_version`: by its id, as the force delete /// does, and from the document and storage flags already read, each with a layer @@ -292,7 +279,7 @@ mod tests { ) .expect("expected to store the document"); - let mut by_id_estimates = estimate.then(HashMap::new); + let mut by_id_estimates = estimate.then(EstimatedLayers::new); let by_id = drive .force_delete_document_for_contract_operations( document.id(), @@ -305,7 +292,7 @@ mod tests { platform_version, ) .expect("expected the force delete operations"); - let mut from_read_estimates = estimate.then(HashMap::new); + let mut from_read_estimates = estimate.then(EstimatedLayers::new); let from_read = drive .force_delete_read_document_for_contract_operations( document.clone(), @@ -327,8 +314,8 @@ mod tests { } /// How many layers `estimates` describes - fn layer_count(estimates: &Option>) -> usize { - estimates.as_ref().map_or(0, HashMap::len) + fn layer_count(estimates: &Option) -> usize { + estimates.as_ref().map_or(0, EstimatedLayers::len) } /// The fee of `operations`, costs estimated from `estimates` when it is set, as an @@ -336,7 +323,7 @@ mod tests { fn fee_of( drive: &Drive, operations: Vec, - estimates: Option>, + estimates: Option, platform_version: &PlatformVersion, ) -> FeeResult { let mut drive_operations = vec![]; @@ -418,7 +405,7 @@ mod tests { &contract, document_type, None, - &mut estimate.then(HashMap::new), + &mut estimate.then(EstimatedLayers::new), 0, None, platform_version, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs index 2397487824f..c1a48696762 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs @@ -1,14 +1,13 @@ use crate::drive::document::expiration::pricing::document_expires_at; use crate::drive::document::expiration::DocumentExpirationEntry; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use grovedb::{Element, TransactionArg}; use dpp::data_contract::document_type::DocumentTypeRef; use std::borrow::Cow; -use std::collections::HashMap; use crate::drive::document::paths::contract_documents_primary_key_path; use crate::util::object_size_info::DocumentInfo; @@ -50,9 +49,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -84,9 +81,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -211,9 +206,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -276,9 +269,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, mut batch_operations: Vec, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/mod.rs index 264aaaf7a97..6b6f32c77cb 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/mod.rs @@ -4,14 +4,13 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::DataContract; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Prepares the operations for deleting a document. @@ -35,9 +34,7 @@ impl Drive { contract: &DataContract, document_type_name: &str, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/v0/mod.rs index cddde01f1b0..c47a3fc14e9 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_with_named_type_operations/v0/mod.rs @@ -1,8 +1,6 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; -use grovedb::{EstimatedLayerInformation, TransactionArg}; - -use std::collections::HashMap; +use grovedb::TransactionArg; use dpp::data_contract::DataContract; @@ -26,9 +24,7 @@ impl Drive { contract: &DataContract, document_type_name: &str, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs index 5f7098a5317..40afd5dc44d 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs @@ -4,6 +4,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::block::block_info::BlockInfo; use dpp::data_contract::document_type::DocumentTypeRef; @@ -13,9 +14,7 @@ use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Prepares the operations for deleting an **indexOnly** document. @@ -50,9 +49,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -108,9 +105,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs index 3ce6123cb9e..983e4ce06e6 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs @@ -1,10 +1,8 @@ -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; +use crate::util::estimated_layers::EstimatedLayers; +use grovedb::TransactionArg; use dpp::data_contract::document_type::{index_only_row_commits_created_at, DocumentTypeRef}; -use std::collections::HashMap; - use crate::util::object_size_info::DocumentInfo::{ DocumentEstimatedAverageSize, DocumentOwnedInfo, }; @@ -51,9 +49,9 @@ impl Drive { ) -> Result { let mut drive_operations: Vec = vec![]; let mut estimated_costs_only_with_layer_info = if apply { - None::> + None:: } else { - Some(HashMap::new()) + Some(EstimatedLayers::new()) }; // With no caller transaction, TTL preparation (direct drainage @@ -113,9 +111,7 @@ impl Drive { contract: &DataContract, document_type: DocumentTypeRef, previous_batch_operations: Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/mod.rs b/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/mod.rs index 7fe17619b5d..b60e53cc94d 100644 --- a/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/mod.rs @@ -3,12 +3,10 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the estimated costs for removing a document to primary storage. @@ -25,7 +23,7 @@ impl Drive { pub fn add_estimation_costs_for_remove_document_to_primary_storage( primary_key_path: [&[u8]; 5], document_type: DocumentTypeRef, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { match platform_version diff --git a/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/v0/mod.rs index f96fafe4fe3..c66e2824a0f 100644 --- a/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/internal/add_estimation_costs_for_remove_document_to_primary_storage/v0/mod.rs @@ -1,4 +1,5 @@ use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; @@ -7,8 +8,6 @@ use grovedb::EstimatedLayerSizes::AllItems; use dpp::data_contract::document_type::DocumentTypeRef; -use std::collections::HashMap; - use crate::drive::constants::{AVERAGE_NUMBER_OF_UPDATES, AVERAGE_UPDATE_BYTE_COUNT_REQUIRED_SIZE}; use crate::util::storage_flags::StorageFlags; @@ -51,7 +50,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_remove_document_to_primary_storage_v0( primary_key_path: [&[u8]; 5], document_type: DocumentTypeRef, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { // we just have the elements diff --git a/packages/rs-drive/src/drive/document/delete/mod.rs b/packages/rs-drive/src/drive/document/delete/mod.rs index e4c97b77008..4ef2e6ee332 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -5,6 +5,7 @@ // Module: delete_document_for_contract // This module contains functionality for deleting a document associated with a given contract + mod delete_document_for_contract; // Module: delete_document_for_contract_id @@ -49,6 +50,7 @@ mod internal; #[cfg(feature = "server")] #[cfg(test)] mod tests { + use crate::util::estimated_layers::EstimatedLayers; use dpp::balances::credits::Creditable; use dpp::block::block_info::BlockInfo; use rand::Rng; @@ -1418,9 +1420,6 @@ mod tests { // which has its own DataContractNotFound error branch (via the // `let Some(...) = ... else { return Err(...); }` pattern). use crate::error::document::DocumentError; - use grovedb::batch::KeyInfoPath; - use grovedb::EstimatedLayerInformation; - use std::collections::HashMap; let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); @@ -1429,9 +1428,7 @@ mod tests { let nonexistent_contract_id = Identifier::from(rand::thread_rng().gen::<[u8; 32]>()); let random_document_id = Identifier::random(); - let mut estimated_costs_only_with_layer_info: Option< - HashMap, - > = None; + let mut estimated_costs_only_with_layer_info: Option = None; let err = drive .delete_document_for_contract_id_with_named_type_operations( diff --git a/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/mod.rs index b158eb7ed33..92a7f67d2aa 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/mod.rs @@ -4,13 +4,12 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Removes the document from primary storage. @@ -33,9 +32,7 @@ impl Drive { document_id: Identifier, document_type: DocumentTypeRef, contract_documents_primary_key_path: [&[u8]; 5], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/v0/mod.rs index 7f70fb9054d..c6209a6fd4f 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_document_from_primary_storage/v0/mod.rs @@ -1,12 +1,10 @@ use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; -use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; +use grovedb::{MaybeTree, TransactionArg}; use dpp::data_contract::document_type::DocumentTypeRef; -use std::collections::HashMap; - use crate::util::grove_operations::BatchDeleteApplyType::{ StatefulBatchDelete, StatelessBatchDelete, }; @@ -31,9 +29,7 @@ impl Drive { document_id: Identifier, document_type: DocumentTypeRef, contract_documents_primary_key_path: [&[u8]; 5], - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs index 2439819a2a7..97b75cabb6a 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs @@ -5,6 +5,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentAndContractInfo; use crate::util::object_size_info::PathInfo; use crate::util::storage_flags::StorageFlags; @@ -12,9 +13,7 @@ use crate::util::storage_flags::StorageFlags; use dpp::data_contract::document_type::IndexLevel; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{TransactionArg, TreeType}; impl Drive { /// Removes indices for an index level and recurses. @@ -56,9 +55,7 @@ impl Drive { parent_value_tree_type: TreeType, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, skip_missing_expired_entry: bool, event_id: [u8; 32], transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v0/mod.rs index de5263542e2..78c0857028b 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v0/mod.rs @@ -1,4 +1,4 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::AllSubtrees; @@ -7,7 +7,6 @@ use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use dpp::data_contract::document_type::IndexLevel; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; @@ -45,9 +44,7 @@ impl Drive { parent_value_tree_is_range_countable: bool, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, event_id: [u8; 32], transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v1/mod.rs index 18c8a7ce2fc..54c011c843b 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v1/mod.rs @@ -1,4 +1,4 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::AllSubtrees; @@ -7,7 +7,6 @@ use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use dpp::data_contract::document_type::IndexLevel; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; @@ -53,9 +52,7 @@ impl Drive { parent_value_tree_type: TreeType, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, event_id: [u8; 32], transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs index 62c535a9f7c..451261a85cd 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs @@ -1,4 +1,4 @@ -use grovedb::batch::KeyInfoPath; +use crate::util::estimated_layers::EstimatedLayers; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; use grovedb::EstimatedLayerSizes::AllSubtrees; @@ -8,7 +8,6 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::{IndexLevel, IndexType}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ @@ -68,9 +67,7 @@ impl Drive { parent_value_tree_type: TreeType, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, skip_missing_expired_entry: bool, event_id: [u8; 32], transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs index 0b431087937..25a2012d24d 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs @@ -3,6 +3,7 @@ mod v1; mod v2; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentAndContractInfo; use crate::error::drive::DriveError; @@ -12,10 +13,7 @@ use crate::fees::op::LowLevelDriveOperation; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; - -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Removes indices for the top index level and calls for lower levels. @@ -36,9 +34,7 @@ impl Drive { &self, document_and_contract_info: &DocumentAndContractInfo, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v0/mod.rs index 67d2ad620a4..54f9b6cf217 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -5,7 +6,6 @@ use grovedb::EstimatedLayerSizes::AllSubtrees; use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::document::unique_event_id; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; @@ -31,9 +31,7 @@ impl Drive { &self, document_and_contract_info: &DocumentAndContractInfo, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs index e0c64f5906a..c3ea9fabc94 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs @@ -1,3 +1,4 @@ +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -5,7 +6,6 @@ use grovedb::EstimatedLayerSizes::AllSubtrees; use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::unique_event_id; @@ -32,9 +32,7 @@ impl Drive { &self, document_and_contract_info: &DocumentAndContractInfo, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, batch_operations: &mut Vec, platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 5e4329a8952..383286f4fa0 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -1,3 +1,4 @@ +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -5,7 +6,6 @@ use grovedb::EstimatedLayerSizes::AllSubtrees; use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ @@ -50,9 +50,7 @@ impl Drive { &self, document_and_contract_info: &DocumentAndContractInfo, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, block_time_ms: u64, transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/mod.rs index e99130c4c72..2a86b1c2143 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/mod.rs @@ -5,16 +5,14 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::{DocumentAndContractInfo, PathInfo}; use crate::util::storage_flags::StorageFlags; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; - use dpp::data_contract::document_type::IndexLevelTypeInfo; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Removes the terminal reference. @@ -48,9 +46,7 @@ impl Drive { all_fields_null: bool, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, skip_missing_expired_entry: bool, event_id: [u8; 32], transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs index 825a826c659..bdc873c212c 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs @@ -1,5 +1,5 @@ +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::key_info::KeyInfo::KnownKey; -use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; @@ -8,7 +8,6 @@ use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; use dpp::data_contract::document_type::IndexLevelTypeInfo; use dpp::data_contract::document_type::IndexType::{ContestedResourceIndex, NonUniqueIndex}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::document_reference_size; @@ -43,9 +42,7 @@ impl Drive { all_fields_null: bool, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, event_id: [u8; 32], transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs index b92d4a98df9..56529d9ded7 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs @@ -1,5 +1,5 @@ +use crate::util::estimated_layers::EstimatedLayers; use grovedb::batch::key_info::KeyInfo::KnownKey; -use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::PotentiallyAtMaxElements; use grovedb::EstimatedLayerSizes::{AllItems, AllReference, AllSubtrees}; @@ -8,7 +8,6 @@ use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; use dpp::data_contract::document_type::IndexLevelTypeInfo; use dpp::data_contract::document_type::IndexType::{ContestedResourceIndex, NonUniqueIndex}; use grovedb::EstimatedSumTrees::NoSumTrees; -use std::collections::HashMap; use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::document_reference_size; @@ -51,9 +50,7 @@ impl Drive { all_fields_null: bool, storage_flags: &Option<&StorageFlags>, previous_batch_operations: &Option<&mut Vec>, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, skip_missing_expired_entry: bool, event_id: [u8; 32], transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/mod.rs index 6cb78e48f5a..5aa0501bc79 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/mod.rs @@ -1,11 +1,9 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentAndContractInfo; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; mod v0; @@ -40,7 +38,7 @@ impl Drive { >( document_and_contract_info: &DocumentAndContractInfo, primary_key_path: [&[u8]; N], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { match platform_version diff --git a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/v0/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/v0/mod.rs index b8c7444e858..caca1e34a30 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_contested_document_to_primary_storage/v0/mod.rs @@ -1,4 +1,5 @@ use crate::drive::constants::{AVERAGE_NUMBER_OF_UPDATES, AVERAGE_UPDATE_BYTE_COUNT_REQUIRED_SIZE}; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::drive::Drive; @@ -15,7 +16,6 @@ use grovedb::EstimatedLayerSizes::AllItems; use grovedb::{EstimatedLayerInformation, TreeType}; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; -use std::collections::HashMap; impl Drive { /// Adds estimated storage costs for adding a document to primary storage. @@ -51,7 +51,7 @@ impl Drive { >( document_and_contract_info: &DocumentAndContractInfo, primary_key_path: [&[u8]; N], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { if document_and_contract_info diff --git a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/mod.rs index 7fb0e137506..f644752972f 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/mod.rs @@ -1,11 +1,9 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentAndContractInfo; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; mod v0; @@ -38,7 +36,7 @@ impl Drive { pub(crate) fn add_estimation_costs_for_add_document_to_primary_storage( document_and_contract_info: &DocumentAndContractInfo, primary_key_path: [&[u8]; 5], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { match platform_version diff --git a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/v0/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/v0/mod.rs index c7f22c9f20d..aff84c5d75d 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/add_estimation_costs_for_add_document_to_primary_storage/v0/mod.rs @@ -1,6 +1,7 @@ use crate::drive::constants::{AVERAGE_NUMBER_OF_UPDATES, AVERAGE_UPDATE_BYTE_COUNT_REQUIRED_SIZE}; use crate::drive::document::paths::contract_documents_keeping_history_primary_key_path_for_document_id; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::storage_flags::StorageFlags; use crate::drive::Drive; @@ -23,7 +24,6 @@ use grovedb::{EstimatedLayerInformation, TreeType}; use crate::util::type_constants::{ DEFAULT_FLOAT_SIZE, DEFAULT_FLOAT_SIZE_U8, DEFAULT_HASH_SIZE_U8, }; -use std::collections::HashMap; impl Drive { /// Adds estimated storage costs for adding a document to primary storage. @@ -57,7 +57,7 @@ impl Drive { pub(super) fn add_estimation_costs_for_add_document_to_primary_storage_v0( document_and_contract_info: &DocumentAndContractInfo, primary_key_path: [&[u8]; 5], - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, platform_version: &PlatformVersion, ) -> Result<(), Error> { let document = if let Some(document) = document_and_contract_info diff --git a/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/mod.rs index d31bad5cf55..a9c56ffed5b 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/mod.rs @@ -1,5 +1,6 @@ mod v0; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteUpTreeApplyType; use crate::drive::Drive; @@ -10,9 +11,7 @@ use crate::error::drive::DriveError; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, EstimatedLayerSizes, MaybeTree}; - -use std::collections::HashMap; +use grovedb::{EstimatedLayerSizes, MaybeTree}; impl Drive { /// Tries to perform a stateless deletion of non-tree elements based on the specified platform version. @@ -40,9 +39,7 @@ impl Drive { element_estimated_sizes: EstimatedLayerSizes, key_info_path: &KeyInfoPath, is_known_to_be_subtree_with_sum: Option, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, platform_version: &PlatformVersion, ) -> Result { match platform_version diff --git a/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/v0/mod.rs b/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/v0/mod.rs index 110ac14bf8f..ceba6ea5b65 100644 --- a/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/estimation_costs/stateless_delete_of_non_tree_for_costs/v0/mod.rs @@ -1,4 +1,5 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteUpTreeApplyType; @@ -11,7 +12,6 @@ use grovedb::batch::KeyInfoPath; use grovedb::{EstimatedLayerInformation, EstimatedLayerSizes, MaybeTree}; use intmap::IntMap; use itertools::Itertools; -use std::collections::HashMap; impl Drive { /// Attempts a stateless deletion of non-tree elements for costs estimation. @@ -42,9 +42,7 @@ impl Drive { element_estimated_sizes: EstimatedLayerSizes, key_info_path: &KeyInfoPath, is_known_to_be_subtree_with_sum: Option, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, ) -> Result { // Keep for debugging // if estimated_costs_only_with_layer_info.is_some() { diff --git a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs index 4aa9d92520e..3147f4308e6 100644 --- a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs @@ -5,11 +5,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Gathers the operations writing a document's entry in the documents expirations tree: @@ -36,9 +35,7 @@ impl Drive { document_id: Option<[u8; 32]>, entry: &DocumentExpirationEntry, expires_at_ms: TimestampMillis, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, previous_batch_operations: &mut Option<&mut Vec>, transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs index 0944b6b54c1..c4335df7c51 100644 --- a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs @@ -5,6 +5,7 @@ use crate::drive::document::expiration::DocumentExpirationEntry; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::util::object_size_info::PathKeyElementInfo::{PathKeyElement, PathKeyElementSize}; use crate::util::object_size_info::{DriveKeyInfo, PathInfo, PathKeyElementInfo}; @@ -12,8 +13,7 @@ use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; use grovedb::batch::key_info::KeyInfo; use grovedb::batch::KeyInfoPath; -use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use std::collections::HashMap; +use grovedb::{Element, TransactionArg, TreeType}; impl Drive { #[inline(always)] @@ -23,9 +23,7 @@ impl Drive { document_id: Option<[u8; 32]>, entry: &DocumentExpirationEntry, expires_at_ms: TimestampMillis, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, previous_batch_operations: &mut Option<&mut Vec>, transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs index e2d0c0e435a..4f3e3c987f1 100644 --- a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs @@ -3,11 +3,9 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::util::estimated_layers::EstimatedLayers; use dpp::prelude::TimestampMillis; use dpp::version::drive_versions::DriveVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::EstimatedLayerInformation; -use std::collections::HashMap; impl Drive { /// Adds the layers a write to the documents expirations tree goes through to a dry run's @@ -26,7 +24,7 @@ impl Drive { pub(crate) fn add_estimation_costs_for_document_expiration( expires_at_ms: TimestampMillis, entry_value_size: u32, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, drive_version: &DriveVersion, ) -> Result<(), Error> { match drive_version diff --git a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs index 9a5d08103c9..4de1a73caf6 100644 --- a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs @@ -3,6 +3,7 @@ use crate::drive::document::expiration::paths::{ }; use crate::drive::system::misc_path_vec; use crate::drive::Drive; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::type_constants::{DEFAULT_HASH_SIZE_U8, U64_SIZE_U8}; use dpp::prelude::TimestampMillis; use grovedb::batch::KeyInfoPath; @@ -10,14 +11,13 @@ use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; use grovedb::EstimatedSumTrees::{NoSumTrees, SomeSumTrees}; use grovedb::{EstimatedLayerInformation, TreeType}; -use std::collections::HashMap; impl Drive { #[inline(always)] pub(super) fn add_estimation_costs_for_document_expiration_v0( expires_at_ms: TimestampMillis, entry_value_size: u32, - estimated_costs_only_with_layer_info: &mut HashMap, + estimated_costs_only_with_layer_info: &mut EstimatedLayers, ) { // The root: `Misc` sits on the fourth level of the root tree, below `Votes`, and the // root holds sum trees beside plain ones. This is the estimate every write under `Misc` diff --git a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs index 49f15f362a7..7c2244aa8c7 100644 --- a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs @@ -4,11 +4,10 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::HashMap; +use grovedb::TransactionArg; impl Drive { /// Gathers the operations removing a document's entry from the documents expirations @@ -35,9 +34,7 @@ impl Drive { document_id: [u8; 32], expires_at_ms: TimestampMillis, entry_value_size: u32, - estimated_costs_only_with_layer_info: &mut Option< - HashMap, - >, + estimated_costs_only_with_layer_info: &mut Option, check_existing_operations: &Option<&mut Vec>, transaction: TransactionArg, batch_operations: &mut Vec, diff --git a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs index 1224ca46379..1df2ad37a98 100644 --- a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs @@ -2,6 +2,7 @@ use crate::drive::document::expiration::paths::documents_expirations_at_time_pat use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; use crate::util::grove_operations::BatchDeleteUpTreeApplyType; use crate::util::type_constants::{DEFAULT_HASH_SIZE_U8, U64_SIZE_U8}; use dpp::prelude::TimestampMillis; @@ -12,7 +13,6 @@ use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; use intmap::IntMap; -use std::collections::HashMap; /// Where the removal stops climbing: it removes the entry (a key of `Misc / E /