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 cd745a85c75..a3606515dfc 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -477,6 +477,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 9f46daef6e8..2fa6f2c61be 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"]],\ @@ -3068,6 +3073,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 9aeb4627f89..dcb483395d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,6 +2104,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "token-history-contract", + "token-market-contract", "wallet-utils-contract", "withdrawals-contract", ] @@ -6611,6 +6612,7 @@ dependencies = [ "arc-swap", "dash-async", "dash-context-provider", + "data-contracts", "dpp", "futures", "gloo-timers", @@ -7873,6 +7875,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "token-market-contract" +version = "5.0.1" +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 25e005dedc4..d362da97f4b 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/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/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index c1743a09bfb..25d52b69cae 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -49,3 +49,44 @@ 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`, `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 +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'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 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 +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 ebf942edca9..24548b21a7a 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 425f1f438c8..e754810278f 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -306,6 +306,7 @@ all-system_contracts = [ "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "token-market-contract", ] # Individual data contract features @@ -322,6 +323,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..d29cc435c1b 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,64 @@ 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(), - ) - })?; + // 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(), + )); + } - 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/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 470361285da..548d45427f1 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::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; 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-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 0e0513c4c13..1196e11841a 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) + } } } } @@ -175,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 @@ -1451,3 +1461,114 @@ 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, 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, + /// 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" + ); + // 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, + 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)); + // 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"] + ); + } + } + + #[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 4b8870fa3f1..d59929381ca 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 @@ -915,7 +915,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, and create the vote decisions trees that yes/no polls live in. + /// trigger, create the vote decisions trees that yes/no polls live in, and add the token + /// marketplace: its escrow trees and the token market contract. fn transition_to_version_15( &self, block_info: &BlockInfo, @@ -943,6 +944,30 @@ impl Platform { self.drive .insert_vote_decisions_trees(Some(transaction), platform_version)?; + // Token marketplace escrow trees (v15 note 12): 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(()) } } @@ -3987,6 +4012,206 @@ 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, + ]; + // `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 \ + 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" + ); + } + + // 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] + 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/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-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..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 @@ -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,10 +30,177 @@ 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, }), } } + +#[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/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/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/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 5afe657671d..032c9e5b510 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() ), - "24e77c57edbf5294e7b43df5af21fcd055697120124f3912021c2e41c954f4e0" + "c724fb4afbd5c5959c1ab8e209b89b600cf166d0a4d6bc3147ae097d3a6dd560" ); assert_eq!( pool.platform @@ -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(), @@ -2158,39 +2160,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "a80509bb285ede04d5c921e432a6d38021bfa2b38e2855cf8f0e735fd20285d9", + "43301475271c0c498d385816e2c08f9aa6d7a4d6ad39347d6ba2be8866c7a909", ), "valid-payment" => ( "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "f6694678f1b835c476af2012fd217c67524f4f8d3ca5e4f9facbcec0b205081b", + "3ccd291b5ce08c413a070e5a94fd77336ecdc1f5df7a297f9630a48ad0ef8cab", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "4a1e0a2664ef5d529920510c003340cf5ecdc443d7478b08974a821076a7c8cc", + "35bd4de4fb98145bf957afce01e6a8a76e866a9f2fec87484bf8393829832468", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "a3cbb2d69941b402cb5fc9c564da81d4716a168355fe4ce4d3ae530d99e40247", + "afa45990edeee57e5f44d711ef39bc692993dd4355bf55d9daa1417926242b60", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "20b47800939b417e433c695208924af0582df67c33edbf085f1fba297c9e6a06", + "97d53c3700f66a273549db7a6a399d5d91cc1e7edca3f97d9355fc1c222711ef", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "5d22e1accc47d79db4b31a93e35a88b70e04412884239c4e04a05a0e2dde7f95", + "bcbd1b6b2b62b3679df9c14c15e08274224c38467a788a453b20b66aeb6037c1", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "bc346f8d036f51e82f5f7af69a9ece71f34501e21c848f1d9df7e17c76dd847f", + "fb0846d5fc2a6292ff27765dc6d296e203b3c9ee945fc5a106dbac3ad5955bb6", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "2b6f1d4e5b67d252c1aa76c466e716106107a98ec96d64eda488c37216611cd9", + "2adeca331188765b37a7c7911f626ad6cf8240b4fca7860b71b8737000f7bc38", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "764422c7f8a87ad8165a7d38c795dcb57ea67d092fa751e7f0c50c06d52fbba9", + "1b9ee20bc36177e67097a03c43bcb77637d534fce2f5d2cefc7be390a53e5f5d", ), _ => unreachable!("enumerated characterization case"), }; 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/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 5615ae6ea1d..bae7c536415 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": { @@ -6291,7 +6395,10 @@ "tree": { "hex": "80", "left": { - "hex": "40" + "hex": "40", + "left": { + "hex": "20" + } }, "right": { "hex": "c0" @@ -6395,7 +6502,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 ac3949fbdaf..03f0465ddde 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; @@ -184,6 +185,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`. /// @@ -228,6 +238,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), @@ -457,6 +469,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/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/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/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/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..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/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/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/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..b8b81b141a6 --- /dev/null +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v2/mod.rs @@ -0,0 +1,26 @@ +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 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([]), + 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/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..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,9 @@ 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 ddd56681587..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,6 +53,9 @@ impl Drive { total_tokens_in_platform, total_identity_token_balances, total_token_shielded_pool_balances, + // 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/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/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..1c09ed055f7 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_balances/v1/mod.rs @@ -0,0 +1,33 @@ +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 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([]), + 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/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..362f195619f --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/credits/mod.rs @@ -0,0 +1,153 @@ +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 + { + Some(0) => self.add_to_credit_order_escrow_operations_v0( + order_id, + credits, + estimated_costs_only_with_layer_info, + platform_version, + ), + 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], + })), + } + } + + /// 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 + { + Some(0) => self.set_credit_order_escrow_operations_v0( + order_id, + credits, + estimated_costs_only_with_layer_info, + platform_version, + ), + 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], + })), + } + } + + /// 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 + { + Some(0) => self.remove_credit_order_escrow_operations_v0( + order_id, + estimated_costs_only_with_layer_info, + platform_version, + ), + 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], + })), + } + } + + /// 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 + { + Some(0) => self.fetch_credit_order_escrow_v0( + order_id, + transaction, + drive_operations, + platform_version, + ), + 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/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..56956a66e20 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/mod.rs @@ -0,0 +1,73 @@ +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 + { + Some(0) => { + Self::add_estimation_costs_for_token_order_escrow_v0( + token_id, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + 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], + })), + } + } + + /// 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 + { + Some(0) => { + Self::add_estimation_costs_for_credit_order_escrow_v0( + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + 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 new file mode 100644 index 00000000000..87972f82b89 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/estimated_costs/v0/mod.rs @@ -0,0 +1,135 @@ +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::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}; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::{AllBigSumTrees, AllSumTrees, NoSumTrees, SomeSumTrees}; +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; + +/// 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 + /// 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, + ) { + // 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, + estimated_layer_count: EstimatedLevel(2, false), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, 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. 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()), + 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()), + 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, 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, + ) { + insert_estimated_layer_keeping_deeper( + estimated_costs_only_with_layer_info, + 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, + ), + }, + ); + + 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( + CREDIT_ORDER_ESCROW_LEVEL_IN_PREFUNDED_BALANCES, + 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..9eb393d9f6a --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -0,0 +1,1215 @@ +//! 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::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, GroveDb, TreeType}; + use std::collections::{BTreeMap, HashMap}; + + 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, + &mut 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, + &mut 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")); + } + + 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()); + } + + /// 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); + } + } + + /// 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"); + } + + /// 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"); + } + + /// 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)), + ("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: 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, + 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 (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)| { + 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/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..c3424d56e56 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/mod.rs @@ -0,0 +1,160 @@ +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. + /// + /// `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], + order_id: [u8; 32], + amount: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option< + 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 { + Some(0) => self.add_to_token_order_escrow_operations_v0( + token_id, + order_id, + amount, + estimated_costs_only_with_layer_info, + transaction, + previous_batch_operations, + platform_version, + ), + 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], + })), + } + } + + /// 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 { + Some(0) => self.set_token_order_escrow_operations_v0( + token_id, + order_id, + amount, + estimated_costs_only_with_layer_info, + platform_version, + ), + 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], + })), + } + } + + /// 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 + { + Some(0) => self.remove_token_order_escrow_operations_v0( + token_id, + order_id, + estimated_costs_only_with_layer_info, + platform_version, + ), + 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], + })), + } + } + + /// 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 + { + Some(0) => self.fetch_token_order_escrow_v0( + token_id, + order_id, + transaction, + drive_operations, + platform_version, + ), + 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/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..fe6dc2a791d --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/tokens/v0/mod.rs @@ -0,0 +1,158 @@ +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 { + #[allow(clippy::too_many_arguments)] + 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, + previous_batch_operations: &mut Option<&mut Vec>, + 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, 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, + previous_batch_operations, + &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/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/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 978dd03afc1..fb21f3d998c 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1770,6 +1770,43 @@ 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, + &mut 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. @@ -1884,6 +1921,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-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..1fbb805866b --- /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: 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 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, + 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..a6c4fa00ac8 --- /dev/null +++ b/packages/rs-drive/src/util/estimated_layers.rs @@ -0,0 +1,407 @@ +//! 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; + +/// 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 existing.estimated_layer_count.estimate_levels() + >= layer.estimated_layer_count.estimate_levels() + { + return; + } + } + estimated_costs_only_with_layer_info.insert(path, layer); +} + +/// 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(()) +} + +/// 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::{ + ApproximateElements, EstimatedLevel, PotentiallyAtMaxElements, + }; + 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), + } + } + + fn level_at( + estimation: &HashMap, + path: &KeyInfoPath, + ) -> Option { + estimation + .get(path) + .and_then(|layer| match layer.estimated_layer_count { + EstimatedLevel(level, _) => Some(level), + _ => None, + }) + } + + #[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 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: ApproximateElements(3), + ..layer(0) + }, + ); + 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 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)); + } + + #[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-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 3937f173b5f..5f7fc423ef3 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}; @@ -490,7 +491,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, @@ -498,6 +499,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() @@ -516,7 +523,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) @@ -530,6 +537,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 ceeb56097bb..bdaebbb6ffc 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 @@ -18,6 +18,9 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// 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; +/// - `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. /// - `contract_storage_serialization_version`, 2 rather than 1: Drive stores a contract in /// format version 2, its document types compiled, while state transitions and clients keep /// format version 1. @@ -39,6 +42,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/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 6557dfc7039..1adf4b1c5e9 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. /// - 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. @@ -51,6 +54,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { advanced_structure: 2, // a tradeable token must be transferable, checked in check_tx too 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 @@ -59,10 +63,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..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,7 +1,8 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveTokenMethodVersions { @@ -10,6 +11,26 @@ 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, +} + +/// 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: 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 aa16f2f4b37..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 @@ -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's escrow is active from protocol version 15 + // (`DRIVE_TOKEN_METHOD_VERSIONS_V3`). + order_escrow: DriveTokenOrderEscrowMethodVersions { + 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 new file mode 100644 index 00000000000..84a628706b2 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs @@ -0,0 +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, 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; 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 ff1ce116d60..6189c2b976e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -1,16 +1,48 @@ use crate::version::drive_versions::drive_contract_method_versions::v5::DRIVE_CONTRACT_METHOD_VERSIONS_V5; +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::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, DriveInitializationMethodVersions, DriveMethodVersions, + DrivePrefundedSpecializedMethodVersions, DriveVersion, +}; -/// Drive version 10. -/// Introduced in protocol v15: +/// Drive version 10, introduced in protocol v15: /// -/// * **Compiled contract storage**: `DRIVE_CONTRACT_METHOD_VERSIONS_V5`: applying a contract -/// writes nothing when its format version 1 bytes match the stored contract's, whatever -/// format the stored one is in, as contracts are stored compiled from this version +/// * **Compiled contracts**: with `DRIVE_CONTRACT_METHOD_VERSIONS_V5`, applying a contract writes +/// nothing when its format version 1 bytes match the stored contract's, whatever format the +/// stored one is in, as contracts are stored compiled from this version /// (`contract_storage_serialization_version` 2). +/// * **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; 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; +/// 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. 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. Beyond the estimators, `apply_drive_operations` 2 lowers +/// each operation of an estimated batch with layer descriptions of its own and merges them +/// 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. /// * **Yes/no vote polls**: `DRIVE_VOTE_METHOD_VERSIONS_V4` bumps /// `insert.register_identity_vote` to 1, which registers a vote of either poll kind, and /// `cleanup.remove_all_votes_given_by_identities` to 1, which also removes the yes/no votes of @@ -25,9 +57,35 @@ use crate::version::drive_versions::{DriveMethodVersions, DriveVersion}; /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { methods: DriveMethodVersions { - contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: compiled contract storage - 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 + 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; 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_V5.fee_pots + }, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V5 // changed in v10: apply_contract_with_serialization 1 (compiled contract storage) + }, + identity: DriveIdentityMethodVersions { + cost_estimation: DriveIdentityCostEstimationMethodVersions { + 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 + }, + 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 + }, + 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 + }, + 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 ..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 855e4a41567..09f32f9c1f0 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 12.) /// /// 4. **`countOf` and `sumOf` match byte array keys**: `CONTRACT_VERSIONS_V7` /// selects `parse_property_constraints` 1, whose grammar binds a filter key @@ -223,13 +224,57 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// (`DRIVE_VERIFY_METHOD_VERSIONS_V4`). /// Nothing calls the opener yet, so at this version every yes/no vote is /// refused for want of a poll. +/// +/// 11. **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. +/// +/// 12. **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. 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; and +/// `apply_drive_operations` 2 merges each operation's estimated layer descriptions into the +/// 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 +/// (`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. 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) + 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); 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 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 - 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); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10) + 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); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); data trigger bindings 3 refuse a replace or a delete of a token market order (note 12) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -241,7 +286,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_storage_serialization_version 2 stores contracts compiled (note 8) + 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_storage_serialization_version 2 stores contracts compiled (note 8); apply_generated_from 1 admits sys.tokens.decimals (note 11) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V3, // changed: the yes/no poll's stored info, id, parameter rules and decision rules are active (note 10) @@ -250,7 +295,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 12) fee_version: FEE_VERSION3, system_limits: SYSTEM_LIMITS_V5, // changed: max_compiled_contract_size caps a contract stored compiled at 1 MiB (note 8) consensus: ConsensusVersions { 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..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", @@ -46,6 +49,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 +62,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..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}; @@ -24,9 +25,10 @@ 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}; +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( @@ -34,6 +36,7 @@ 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; +use dpp::version::feature_initial_protocol_versions::TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use futures::future::{BoxFuture, FutureExt}; @@ -1032,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, + // whichever contract features this build has. + 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) { @@ -1050,6 +1062,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 +1207,22 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any(feature = "token-market-contract", feature = "all-system-contracts"))] + // 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, + ) + .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 +2547,94 @@ 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); + + // 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()); + } + + /// 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 id = SystemDataContract::TokenMarket.id(); + let contract = DataContract::V1(DataContractV1 { + 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(&id, PlatformVersion::get(14).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + assert!(provider + .get_data_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) diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 8ed5f0992dd..ebd3ba69b01 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..425fbc3fc50 --- /dev/null +++ b/packages/token-market-contract/README.md @@ -0,0 +1,63 @@ +# 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 `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, 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` + +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`, `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. +- `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..f49cd5a3fbd --- /dev/null +++ b/packages/token-market-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/token-market-contract", + "version": "5.0.1", + "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..e1ce44a0402 --- /dev/null +++ b/packages/token-market-contract/schema/v1/token-market-contract-documents.json @@ -0,0 +1,221 @@ +{ + "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" + }, + { + "decimals": "asc" + }, + { + "price": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "depth", + "properties": [ + { + "tokenId": "asc" + }, + { + "decimals": "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 order'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 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "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 + } + }, + "required": [ + "tokenId", + "price", + "amount", + "remaining", + "decimals", + "$createdAt", + "$updatedAt" + ], + "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" + }, + { + "decimals": "asc" + }, + { + "price": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "depth", + "properties": [ + { + "tokenId": "asc" + }, + { + "decimals": "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 order'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 + }, + "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": 4 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "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 + } + }, + "required": [ + "tokenId", + "price", + "amount", + "remaining", + "decimals", + "escrow", + "$createdAt", + "$updatedAt" + ], + "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..436f487cd99 --- /dev/null +++ b/packages/token-market-contract/src/lib.rs @@ -0,0 +1,139 @@ +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::REMAINING, + ); + declared( + bid::NAME, + &[ + bid::properties::TOKEN_ID, + bid::properties::PRICE, + bid::properties::AMOUNT, + bid::properties::REMAINING, + 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..0c1af4cbe67 --- /dev/null +++ b/packages/token-market-contract/src/v1/mod.rs @@ -0,0 +1,51 @@ +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 DECIMALS: &str = "decimals"; + } + + 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 ESCROW: &str = "escrow"; + pub const DECIMALS: &str = "decimals"; + } + + 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..5fa94864e42 --- /dev/null +++ b/packages/token-market-contract/test/unit/tokenMarketContract.spec.js @@ -0,0 +1,126 @@ +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(); + + // 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) => { + 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, + decimals: 2, + ...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', 'decimals'].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'].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 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' })); + + 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 4df9d72f564..ded8871df57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2011,6 +2011,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"