From 180adb9b1d7e9658666e6a3c467f4769ffb18518 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 17:10:11 -0500 Subject: [PATCH 1/6] feat(platform)!: add the app-connect system contract (protocol 14) One on-chain id per network for the wallet-to-dApp login handshake: the wallet's encrypted loginKeyResponse and the app's appManifest. Registered at genesis from protocol version 14 and inserted by transition_to_version_14 on chains upgrading from 13, the way DocumentHistory activated at 13. Contract id H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ, SystemDataContract::AppConnect = 9, app_connect: 1 in SYSTEM_DATA_CONTRACT_VERSIONS_V3. The loginKeyResponse index is deliberately not unique: the request id is public, so a unique index without the owner would let anyone block the wallet's write; the app authenticates each candidate by decrypting it instead. Co-Authored-By: Claude Fable 5.1 --- .../scripts/review_transition.sh | 4 +- .codecov.yml | 1 + .../package-filters/js-packages-direct.yml | 3 + .../js-packages-no-workflows.yml | 4 + .github/package-filters/js-packages.yml | 5 + .../package-filters/rs-packages-direct.yml | 5 + .../rs-packages-no-workflows.yml | 6 + .github/package-filters/rs-packages.yml | 7 + .../package-filters/test-suite-triggers.yml | 1 + .github/workflows/tests-rs-workspace.yml | 1 + .github/workflows/tests.yml | 1 + .pnp.cjs | 129 +++-- Cargo.lock | 12 + Cargo.toml | 1 + Dockerfile | 5 + book/src/architecture/overview.md | 2 +- docs/protocol/app-connect.md | 176 ++++++ package.json | 2 + packages/app-connect-contract/.mocharc.yml | 2 + packages/app-connect-contract/Cargo.toml | 15 + packages/app-connect-contract/LICENSE | 20 + packages/app-connect-contract/README.md | 46 ++ .../app-connect-contract/eslint.config.mjs | 10 + .../app-connect-contract/lib/systemIds.js | 4 + packages/app-connect-contract/package.json | 27 + .../v1/app-connect-contract-documents.json | 164 ++++++ packages/app-connect-contract/src/error.rs | 17 + packages/app-connect-contract/src/lib.rs | 63 +++ packages/app-connect-contract/src/v1/mod.rs | 84 +++ .../app-connect-contract/test/bootstrap.js | 30 ++ .../test/unit/appConnectContract.spec.js | 508 ++++++++++++++++++ packages/data-contracts/Cargo.toml | 3 + packages/data-contracts/src/error.rs | 18 + packages/data-contracts/src/lib.rs | 26 +- packages/rs-dpp/Cargo.toml | 2 + packages/rs-dpp/src/system_data_contracts.rs | 5 + .../create_genesis_state/v1/mod.rs | 60 +++ .../v0/mod.rs | 119 +++- .../rs-drive/src/cache/system_contracts.rs | 24 + .../system_data_contract_versions/mod.rs | 1 + .../system_data_contract_versions/v1.rs | 1 + .../system_data_contract_versions/v2.rs | 1 + .../system_data_contract_versions/v3.rs | 7 + packages/rs-sdk-ffi/Cargo.toml | 1 + .../Cargo.toml | 2 + .../src/provider.rs | 14 + packages/rs-sdk/Cargo.toml | 1 + yarn.lock | 14 + 48 files changed, 1595 insertions(+), 59 deletions(-) create mode 100644 docs/protocol/app-connect.md create mode 100644 packages/app-connect-contract/.mocharc.yml create mode 100644 packages/app-connect-contract/Cargo.toml create mode 100644 packages/app-connect-contract/LICENSE create mode 100644 packages/app-connect-contract/README.md create mode 100644 packages/app-connect-contract/eslint.config.mjs create mode 100644 packages/app-connect-contract/lib/systemIds.js create mode 100644 packages/app-connect-contract/package.json create mode 100644 packages/app-connect-contract/schema/v1/app-connect-contract-documents.json create mode 100644 packages/app-connect-contract/src/error.rs create mode 100644 packages/app-connect-contract/src/lib.rs create mode 100644 packages/app-connect-contract/src/v1/mod.rs create mode 100644 packages/app-connect-contract/test/bootstrap.js create mode 100644 packages/app-connect-contract/test/unit/appConnectContract.spec.js diff --git a/.claude/skills/protocol-upgrade-test/scripts/review_transition.sh b/.claude/skills/protocol-upgrade-test/scripts/review_transition.sh index b5438e5d51e..08558d2e23d 100755 --- a/.claude/skills/protocol-upgrade-test/scripts/review_transition.sh +++ b/.claude/skills/protocol-upgrade-test/scripts/review_transition.sh @@ -133,6 +133,7 @@ git diff --name-status "$baseline_sha..$target_sha" -- \ packages/data-contracts \ packages/dpns-contract \ packages/document-history-contract \ + packages/app-connect-contract \ packages/dashmate/configs \ packages/dashmate/src/commands @@ -145,7 +146,8 @@ git diff --dirstat=files,0 "$baseline_sha..$target_sha" -- \ packages/rs-platform-version \ packages/data-contracts \ packages/dpns-contract \ - packages/document-history-contract + packages/document-history-contract \ + packages/app-connect-contract echo echo "Target dispatch and threshold files" diff --git a/.codecov.yml b/.codecov.yml index 432c1cff2a2..712578c8e50 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -52,6 +52,7 @@ ignore: - "packages/data-contracts/src/**" - "packages/dpns-contract/src/**" - "packages/keyword-search-contract/src/**" + - "packages/app-connect-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 a3e768e6901..37f0c53aec4 100644 --- a/.github/package-filters/js-packages-direct.yml +++ b/.github/package-filters/js-packages-direct.yml @@ -10,6 +10,9 @@ '@dashevo/keyword-search-contract': - packages/keyword-search-contract/** +'@dashevo/app-connect-contract': + - packages/app-connect-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 983ffd5f1d2..f14c729cd08 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -10,6 +10,9 @@ '@dashevo/keyword-search-contract': &keyword-search-contract - packages/keyword-search-contract/** +'@dashevo/app-connect-contract': &app-connect-contract + - packages/app-connect-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - packages/dashpay-contract/** @@ -35,6 +38,7 @@ - *token-history-contract - *document-history-contract - *keyword-search-contract + - *app-connect-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 d41985c9d1e..087abed9a45 100644 --- a/.github/package-filters/js-packages.yml +++ b/.github/package-filters/js-packages.yml @@ -14,6 +14,10 @@ - .github/workflows/tests* - packages/keyword-search-contract/** +'@dashevo/app-connect-contract': &app-connect-contract + - .github/workflows/tests* + - packages/app-connect-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/** @@ -45,6 +49,7 @@ - *token-history-contract - *document-history-contract - *keyword-search-contract + - *app-connect-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 441c8137023..e0db8b9abc6 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -18,6 +18,11 @@ keyword-search-contract: - packages/keyword-search-contract/schema/** - packages/keyword-search-contract/Cargo.toml +app-connect-contract: + - packages/app-connect-contract/src/** + - packages/app-connect-contract/schema/** + - packages/app-connect-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 90835d0429f..b0c596ec3a8 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -18,6 +18,11 @@ keyword-search-contract: &keyword-search-contract - packages/keyword-search-contract/schema/** - packages/keyword-search-contract/Cargo.toml +app-connect-contract: &app-connect-contract + - packages/app-connect-contract/src/** + - packages/app-connect-contract/schema/** + - packages/app-connect-contract/Cargo.toml + dashpay-contract: &dashpay-contract - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** @@ -51,6 +56,7 @@ data-contracts: &data-contracts - *token-history-contract - *keyword-search-contract - *document-history-contract + - *app-connect-contract dpp: &dpp - packages/rs-dpp/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 6fae2aa84ab..1174da0c998 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -22,6 +22,12 @@ keyword-search-contract: &keyword-search-contract - packages/keyword-search-contract/schema/** - packages/keyword-search-contract/Cargo.toml +app-connect-contract: &app-connect-contract + - .github/workflows/tests* + - packages/app-connect-contract/src/** + - packages/app-connect-contract/schema/** + - packages/app-connect-contract/Cargo.toml + dashpay-contract: &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/src/** @@ -61,6 +67,7 @@ data-contracts: &data-contracts - *token-history-contract - *keyword-search-contract - *document-history-contract + - *app-connect-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 7f814cd8811..1f60d0ec9f9 100644 --- a/.github/package-filters/test-suite-triggers.yml +++ b/.github/package-filters/test-suite-triggers.yml @@ -30,6 +30,7 @@ run: - packages/token-history-contract/** - packages/document-history-contract/** - packages/keyword-search-contract/** + - packages/app-connect-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 f6a979e1402..67b44711e3c 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -419,6 +419,7 @@ jobs: --package token-history-contract \ --package wallet-utils-contract \ --package keyword-search-contract \ + --package app-connect-contract \ --all-features \ --locked \ -E 'not test(~shield) 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 86d9675f743..667395a4fcd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -309,6 +309,7 @@ jobs: - .github/workflows/swift-sdk-build.yml - .github/workflows/tests.yml - packages/swift-sdk/** + - packages/app-connect-contract/** - packages/dapi-grpc/** - packages/dashpay-contract/** - packages/data-contracts/** diff --git a/.pnp.cjs b/.pnp.cjs index f2c82efb91a..1843bea9e57 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -14,6 +14,10 @@ const RAW_RUNTIME_STATE = "name": "@dashevo/platform",\ "reference": "workspace:."\ },\ + {\ + "name": "@dashevo/app-connect-contract",\ + "reference": "workspace:packages/app-connect-contract"\ + },\ {\ "name": "@dashevo/bench-suite",\ "reference": "workspace:packages/bench-suite"\ @@ -111,6 +115,7 @@ const RAW_RUNTIME_STATE = "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "pnpZipBackend": "libzip",\ "fallbackExclusionList": [\ + ["@dashevo/app-connect-contract", ["workspace:packages/app-connect-contract"]],\ ["@dashevo/bench-suite", ["workspace:packages/bench-suite"]],\ ["@dashevo/dapi", ["workspace:packages/dapi"]],\ ["@dashevo/dapi-client", ["workspace:packages/js-dapi-client"]],\ @@ -2493,6 +2498,22 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@dashevo/app-connect-contract", [\ + ["workspace:packages/app-connect-contract", {\ + "packageLocation": "./packages/app-connect-contract/",\ + "packageDependencies": [\ + ["@dashevo/app-connect-contract", "workspace:packages/app-connect-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/bench-suite", [\ ["workspace:packages/bench-suite", {\ "packageLocation": "./packages/bench-suite/",\ @@ -2545,8 +2566,8 @@ const RAW_RUNTIME_STATE = ["bs58", "npm:4.0.1"],\ ["cbor", "npm:8.1.0"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["dotenv", "npm:8.6.0"],\ ["dotenv-expand", "npm:5.1.0"],\ ["dotenv-safe", "npm:8.2.0"],\ @@ -2562,7 +2583,7 @@ const RAW_RUNTIME_STATE = ["pino-pretty", "npm:10.2.3"],\ ["semver", "npm:7.5.3"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["swagger-jsdoc", "npm:3.7.0"],\ ["ws", "virtual:b375dcefccef90d9158d5f197a75395cffedb61772e66f2efcf31c6c8e30c82a6423e0d52b091b15b4fa72cda43a09256ed00b6ce89b9cfb14074f087b9c8496#npm:8.17.1"]\ ],\ @@ -2586,11 +2607,11 @@ const RAW_RUNTIME_STATE = ["buffer", "npm:6.0.3"],\ ["cbor", "npm:8.1.0"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["comment-parser", "npm:0.7.6"],\ ["core-js", "npm:3.33.2"],\ ["crypto-browserify", "npm:3.12.1"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["events", "npm:3.3.0"],\ ["google-protobuf", "npm:3.19.1"],\ @@ -2607,7 +2628,7 @@ const RAW_RUNTIME_STATE = ["path-browserify", "npm:1.0.1"],\ ["process", "npm:0.11.10"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["stream-browserify", "npm:3.0.0"],\ ["string_decoder", "npm:1.3.0"],\ ["undici", "npm:6.25.0"],\ @@ -2630,15 +2651,15 @@ const RAW_RUNTIME_STATE = ["@grpc/grpc-js", "npm:1.14.3"],\ ["@improbable-eng/grpc-web", "virtual:c60802fb91064892a66eac238372b1f92273bed401eb316b63f9eae73923158c5dcd2982eb1e735f7e36e089d74b3ee3773666256e3b50594593c762aa939877#npm:0.15.0"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["google-protobuf", "npm:3.19.1"],\ ["long", "npm:5.2.0"],\ ["mocha", "npm:11.1.0"],\ ["mocha-sinon", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.1.2"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2731,11 +2752,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/dashpay-contract", "workspace:packages/dashpay-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2757,11 +2778,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/document-history-contract", "workspace:packages/document-history-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2787,11 +2808,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/dpns-contract", "workspace:packages/dpns-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2849,8 +2870,8 @@ const RAW_RUNTIME_STATE = ["@grpc/proto-loader", "npm:0.5.6"],\ ["cbor", "npm:8.1.0"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["lodash", "npm:4.17.23"],\ ["long", "npm:5.2.0"],\ @@ -2859,7 +2880,7 @@ const RAW_RUNTIME_STATE = ["nyc", "npm:15.1.0"],\ ["semver", "npm:7.5.3"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2871,11 +2892,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/keyword-search-contract", "workspace:packages/keyword-search-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2887,11 +2908,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/masternode-reward-shares-contract", "workspace:packages/masternode-reward-shares-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -2941,10 +2962,10 @@ const RAW_RUNTIME_STATE = ["buffer", "npm:6.0.3"],\ ["bufferutil", "npm:4.0.6"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["crypto-browserify", "npm:3.12.1"],\ ["dash", "workspace:packages/js-dash-sdk"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["dotenv-safe", "npm:8.2.0"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["events", "npm:3.3.0"],\ @@ -2969,7 +2990,7 @@ const RAW_RUNTIME_STATE = ["semver", "npm:7.5.3"],\ ["setimmediate", "npm:1.0.5"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["stream-browserify", "npm:3.0.0"],\ ["stream-http", "npm:3.2.0"],\ ["string_decoder", "npm:1.3.0"],\ @@ -3020,11 +3041,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/token-history-contract", "workspace:packages/token-history-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -3045,10 +3066,10 @@ const RAW_RUNTIME_STATE = ["buffer", "npm:6.0.3"],\ ["cbor", "npm:8.1.0"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["crypto-browserify", "npm:3.12.1"],\ ["crypto-js", "npm:4.2.0"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["dotenv-safe", "npm:8.2.0"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["events", "npm:3.3.0"],\ @@ -3071,7 +3092,7 @@ const RAW_RUNTIME_STATE = ["process", "npm:0.11.10"],\ ["setimmediate", "npm:1.0.5"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["stream-browserify", "npm:3.0.0"],\ ["stream-http", "npm:3.2.0"],\ ["string_decoder", "npm:1.3.0"],\ @@ -3093,11 +3114,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/wallet-utils-contract", "workspace:packages/wallet-utils-contract"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -3122,11 +3143,11 @@ const RAW_RUNTIME_STATE = ["bs58", "npm:4.0.1"],\ ["buffer", "npm:6.0.3"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["chai-exclude", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:2.1.0"],\ ["chai-string", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:1.5.0"],\ ["crypto-browserify", "npm:3.12.1"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ ["events", "npm:3.3.0"],\ ["fast-json-patch", "npm:3.1.1"],\ @@ -3145,7 +3166,7 @@ const RAW_RUNTIME_STATE = ["path-browserify", "npm:1.0.1"],\ ["process", "npm:0.11.10"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["stream-browserify", "npm:3.0.0"],\ ["stream-http", "npm:3.2.0"],\ ["string_decoder", "npm:1.3.0"],\ @@ -3243,11 +3264,11 @@ const RAW_RUNTIME_STATE = ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ ["@dashevo/withdrawals-contract", "workspace:packages/withdrawals-contract"],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["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:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -8398,12 +8419,12 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1", {\ - "packageLocation": "./.yarn/__virtual__/chai-as-promised-virtual-8795af412c/0/cache/chai-as-promised-npm-7.1.1-cdc17e4612-5d9ecab37b.zip/node_modules/chai-as-promised/",\ + ["virtual:98d1afeac78a19485e4cb7428abff692e58b6fc468d8040035b560ed49383fc95857be6b5014af27e53063e6f08b654690c2b945f3443c22dd60c6b083684b3c#npm:7.1.1", {\ + "packageLocation": "./.yarn/__virtual__/chai-as-promised-virtual-d444a37be5/0/cache/chai-as-promised-npm-7.1.1-cdc17e4612-5d9ecab37b.zip/node_modules/chai-as-promised/",\ "packageDependencies": [\ - ["@types/chai", null],\ + ["@types/chai", "npm:4.3.20"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:98d1afeac78a19485e4cb7428abff692e58b6fc468d8040035b560ed49383fc95857be6b5014af27e53063e6f08b654690c2b945f3443c22dd60c6b083684b3c#npm:7.1.1"],\ ["check-error", "npm:1.0.3"]\ ],\ "packagePeers": [\ @@ -8412,12 +8433,12 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["virtual:98d1afeac78a19485e4cb7428abff692e58b6fc468d8040035b560ed49383fc95857be6b5014af27e53063e6f08b654690c2b945f3443c22dd60c6b083684b3c#npm:7.1.1", {\ - "packageLocation": "./.yarn/__virtual__/chai-as-promised-virtual-d444a37be5/0/cache/chai-as-promised-npm-7.1.1-cdc17e4612-5d9ecab37b.zip/node_modules/chai-as-promised/",\ + ["virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1", {\ + "packageLocation": "./.yarn/__virtual__/chai-as-promised-virtual-d5c799738c/0/cache/chai-as-promised-npm-7.1.1-cdc17e4612-5d9ecab37b.zip/node_modules/chai-as-promised/",\ "packageDependencies": [\ - ["@types/chai", "npm:4.3.20"],\ + ["@types/chai", null],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:98d1afeac78a19485e4cb7428abff692e58b6fc468d8040035b560ed49383fc95857be6b5014af27e53063e6f08b654690c2b945f3443c22dd60c6b083684b3c#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["check-error", "npm:1.0.3"]\ ],\ "packagePeers": [\ @@ -9671,11 +9692,11 @@ const RAW_RUNTIME_STATE = ["begoo", "npm:2.0.2"],\ ["bs58", "npm:4.0.1"],\ ["chai", "npm:4.3.10"],\ - ["chai-as-promised", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:7.1.1"],\ + ["chai-as-promised", "virtual:e2d057e7cc143d3cb9bec864f4a2d862441b5a09f81f8e6c46e7a098cbc89e4d07017cc6e2e2142d5704bb55da853cbec2d025ebc0b30e8696c31380c00f2c7d#npm:7.1.1"],\ ["chalk", "npm:4.1.2"],\ ["cron", "npm:2.1.0"],\ ["dashmate", "workspace:packages/dashmate"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ ["diskusage-ng", "npm:1.0.4"],\ ["dockerode", "npm:4.0.9"],\ ["dot", "npm:1.1.3"],\ @@ -9704,7 +9725,7 @@ const RAW_RUNTIME_STATE = ["rxjs", "npm:6.6.7"],\ ["semver", "npm:7.5.3"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ ["systeminformation", "npm:5.31.1"],\ ["table", "npm:6.8.1"],\ ["tar", "npm:7.5.10"],\ @@ -10228,12 +10249,12 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1", {\ - "packageLocation": "./.yarn/__virtual__/dirty-chai-virtual-a3086bc2f4/0/cache/dirty-chai-npm-2.0.1-acaf82c8df-b4f3d1ea01.zip/node_modules/dirty-chai/",\ + ["virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1", {\ + "packageLocation": "./.yarn/__virtual__/dirty-chai-virtual-7efea90668/0/cache/dirty-chai-npm-2.0.1-acaf82c8df-b4f3d1ea01.zip/node_modules/dirty-chai/",\ "packageDependencies": [\ ["@types/chai", null],\ ["chai", "npm:4.3.10"],\ - ["dirty-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.0.1"]\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"]\ ],\ "packagePeers": [\ "@types/chai",\ @@ -19781,14 +19802,14 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0", {\ - "packageLocation": "./.yarn/__virtual__/sinon-chai-virtual-9ba2472305/0/cache/sinon-chai-npm-3.7.0-8e6588805e-028853eb8a.zip/node_modules/sinon-chai/",\ + ["virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0", {\ + "packageLocation": "./.yarn/__virtual__/sinon-chai-virtual-68fbcb0797/0/cache/sinon-chai-npm-3.7.0-8e6588805e-028853eb8a.zip/node_modules/sinon-chai/",\ "packageDependencies": [\ ["@types/chai", null],\ ["@types/sinon", null],\ ["chai", "npm:4.3.10"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ ],\ "packagePeers": [\ "@types/chai",\ diff --git a/Cargo.lock b/Cargo.lock index a69a3f312c3..98b465c7c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "app-connect-contract" +version = "4.2.0-beta.2" +dependencies = [ + "base58", + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "apple-native-keyring-store" version = "1.0.0" @@ -1859,6 +1870,7 @@ dependencies = [ name = "data-contracts" version = "4.2.0-beta.2" dependencies = [ + "app-connect-contract", "dashpay-contract", "document-history-contract", "dpns-contract", diff --git a/Cargo.toml b/Cargo.toml index 0e161031571..3cff6f79d87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "packages/token-history-contract", "packages/document-history-contract", "packages/keyword-search-contract", + "packages/app-connect-contract", "packages/rs-sdk-ffi", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", diff --git a/Dockerfile b/Dockerfile index 13a762334ce..df982026d38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -401,6 +401,7 @@ COPY --parents \ packages/token-history-contract \ packages/document-history-contract \ packages/keyword-search-contract \ + packages/app-connect-contract \ packages/data-contracts \ packages/strategy-tests \ packages/simple-signer \ @@ -523,6 +524,7 @@ COPY --parents \ packages/token-history-contract \ packages/document-history-contract \ packages/keyword-search-contract \ + packages/app-connect-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ @@ -705,6 +707,7 @@ COPY --parents \ packages/token-history-contract \ packages/document-history-contract \ packages/keyword-search-contract \ + packages/app-connect-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ packages/data-contracts \ @@ -847,6 +850,7 @@ COPY --from=build-dashmate-helper /platform/packages/wallet-utils-contract packa COPY --from=build-dashmate-helper /platform/packages/token-history-contract packages/token-history-contract COPY --from=build-dashmate-helper /platform/packages/document-history-contract packages/document-history-contract 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/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 @@ -950,6 +954,7 @@ COPY --parents \ packages/token-history-contract \ packages/document-history-contract \ packages/keyword-search-contract \ + packages/app-connect-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 f0d24e394f7..c2e61bedba8 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`, `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`, `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/docs/protocol/app-connect.md b/docs/protocol/app-connect.md new file mode 100644 index 00000000000..d6d48bebaae --- /dev/null +++ b/docs/protocol/app-connect.md @@ -0,0 +1,176 @@ +# App Connect: the wallet-to-app login handshake + +Protocol version 14 ships the `app-connect` system contract. It gives the two halves of a +wallet-to-app login one well-known contract id on every network: the wallet's encrypted answer +to an app's login request, and the manifest an app publishes so wallets know what it is and what +it needs. Nothing in the handshake is new consensus behaviour; the contract is the on-chain +mailbox and the directory, and Platform validates its documents like any other. + +The contract id is `H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ` and the owner id is the +all-zero identifier, like the other system contracts. Both document types are stored, mutable, +deletable, and creatable by any identity (`creationRestrictionMode: 0`). + +## Why a system contract + +A wallet meets an app it has never seen through a QR code or a deep link that carries nothing +but the app's contract id. To answer, the wallet needs a place to write that the app already +knows how to read, and a place to look up the app that no one else can squat. A contract with +a fixed id on mainnet, testnet and every devnet gives both without a per-network configuration +table in every wallet and every SDK, and lets nodes serve it from the compiled-in system contract +cache like DPNS or DashPay. + +## The document types + +### `loginKeyResponse` + +Written by the wallet after the user approves a `connect` request. From Platform's point of +view a response is per request: nothing ties it to the identity beyond `$ownerId`, and nothing +stops an identity from holding several. The wallet keeps the document id of its response for +each (identity, app) locally and replaces that document on re-login; if it loses the id it +creates a new one, and the old row stays until someone who finds it deletes it. There is +deliberately no `($ownerId, contractId)` index to make that a rule: it measured at roughly 10 M +credits per document for a constraint the wallet enforces itself. + +| Property | Type | Meaning | +|---|---|---| +| `contractId` | identifier, `refersTo: contract` | The app's data contract. The reference means the contract must exist when the document is written. | +| `appEphemeralPubKeyHash` | 20 bytes | `hash160` of the ephemeral public key the app put in its request. It identifies the request, and it is what the app polls for. | +| `walletEphemeralPubKey` | 33 bytes | The wallet's compressed ephemeral public key. The app combines it with its own ephemeral private key to derive the shared secret. | +| `encryptedPayload` | 60 to 284 bytes | The private keys the wallet grants the app, encrypted to the shared secret: a 28-byte envelope followed by 32 bytes per key, one to eight keys. | + +All four are required. The single index, `byContractAndEphemeralKey` on +`(contractId, appEphemeralPubKeyHash)`, lets the app fetch the answers to its request with a +two-value equality query and a proof. + +The index is deliberately **not** unique. The app's ephemeral public key is public (it is in +the QR code), so a unique index that does not include `$ownerId` would let any observer +pre-create a row under the request id and block the wallet's write. Instead the app +authenticates every candidate it gets back: the payload is AES-GCM, and its tag is computed over +additional data that binds `contractId`, `appEphemeralPubKeyHash` and `walletEphemeralPubKey`, +so only a row written by the party that holds the shared secret decrypts. A squatter's row fails +to decrypt, costs the squatter a document fee, and is ignored. Apps must therefore query by +both index values and try each result rather than assume there is exactly one. + +### `appManifest` + +Published once by the owner of an app's data contract and updated when the app's requirements +change. A wallet only trusts a manifest whose `$ownerId` is the owner of the contract it names. + +| Property | Type | Meaning | +|---|---|---| +| `appContractId` | identifier, `refersTo: contract` | The app's data contract. | +| `name` | string, at most 64 characters | Display name, shown on the wallet's approval sheet. | +| `url` | string, at most 256 characters | The app's URL, shown on the approval sheet. | +| `authBoundsKind` | integer 0 to 3 | The contract bounds the login key must carry: `0` none, `1` the contract in `authBoundsId`, `2` the document type `authBoundsDocType` of that contract, `3` the contract group in `authBoundsId`. | +| `authBoundsId` | 32 bytes, optional | The contract or contract group id the bounds name. Absent when `authBoundsKind` is `0`. | +| `authBoundsDocType` | string, at most 64 characters, optional | The document type name, present only when `authBoundsKind` is `2`. | +| `sessionSeconds` | integer | The login key lifetime the app asks for, in seconds. | +| `sessionBudget` | integer | The login key budget the app asks for, in credits. | +| `encBindings` | 0 to 768 bytes, optional | The encryption key bindings the app needs, packed as fixed 96-byte records (below). | + +`appContractId`, `name`, `url`, `authBoundsKind`, `sessionSeconds` and `sessionBudget` are +required. The single index, `byOwnerAndApp` on `($ownerId, appContractId)`, is unique. Wallets +query by both values, so an identity can publish at most one manifest per app contract and cannot +publish one that a wallet would accept for a contract it does not own. + +The bounds are a requirement: the wallet registers the login key with exactly those +`contractBounds` (see [contract-bound authentication keys](contract-bound-authentication-keys.md)), +or refuses. The lifetime and budget are requests: they cap what the wallet grants, and the wallet +may grant less (see [authentication keys with a budget or an expiry](authentication-key-limits.md)). +`authBoundsKind = 0` is legal; the expiry and budget still apply, only the scope does not. + +#### The `encBindings` record + +Document schemas admit byte arrays but not arrays of objects, so the bindings are packed. Each +record is 96 bytes: + +| Offset | Size | Content | +|---|---|---| +| 0 | 32 | The id of the data contract the keys serve. | +| 32 | 1 | Purpose mask: bit 0 asks for an ENCRYPTION key, bit 1 for a DECRYPTION key. | +| 33 | 63 | The document type name, zero-padded; all zero for a contract-level binding. | + +The array holds zero to eight records, so its length is a multiple of 96 up to 768. For each +record and each purpose bit, the wallet makes sure the identity holds an enabled key with that +purpose, bound to that contract (or that document type of it), and registers one if it does not. +The bound contract or document type has to opt in with `requiresIdentityEncryptionBoundedKey` or +`requiresIdentityDecryptionBoundedKey` for the binding to be registrable. + +Platform does not parse `encBindings`; it is a byte array to consensus, and the layout above is +a convention between apps and wallets. The Rust crate exposes the offsets and the purpose bits as +constants under `app_connect_contract::v1::document_types::app_manifest::enc_bindings`. + +## The login flow + +### The request + +The app shows a QR code, or opens a deep link on the same device, carrying a `connect` URI: + +```text +dashpay://connect?v=2&n=&exp=&app=&e= +``` + +`e` is fresh for every request. Its `hash160` is the request id under which the wallet answers. +The app keeps the matching private key in memory until the answer arrives. + +### The wallet + +1. Fetches the app contract, then the manifest owned by the contract's owner whose + `appContractId` matches. Either missing, the request is refused. +2. Lets the user choose an identity if the wallet holds more than one usable one. +3. Derives the login key for this app and identity. If a key with that hash is already on the + identity and not expired, the request is a retry and the wallet skips to step 6 with it. +4. For every `encBindings` record, checks the identity for the bound keys it asks for and plans + to add the missing ones. +5. Shows the approval sheet: the app's name and URL, the key's lifetime and budget (the wallet's + grant, which the user can shorten), the bounds, and any encryption keys that will be added. + On approval, broadcasts one identity update that adds the login key with the manifest's + bounds and the granted limits, plus any missing bound encryption keys. +6. Encrypts the granted private keys to the app's ephemeral key and creates, or replaces, its + `loginKeyResponse` for this app. If the identity update failed, nothing is published. + +### The app + +Polls the `loginKeyResponse` documents whose `contractId` is its own contract and whose +`appEphemeralPubKeyHash` is `hash160(e)`. For each result the app derives the shared secret from +`walletEphemeralPubKey` and tries to decrypt the payload; the first one whose authentication tag +verifies is the wallet's answer, and the rest are discarded. It then confirms each key's public +half is live on the identity. It is logged in until the key expires or its budget runs out, at +which point it starts a new `connect`. + +### Signing outside the login key's scope + +Anything the login key cannot sign (a DPNS registration, a DashPay contact request, a token +purchase) goes through a separate `sign` request: the app hands the wallet a complete unsigned +state transition, the wallet describes it on a sheet, and the user approves each one. That flow +does not touch this contract. + +## What Platform enforces + +Platform validates both document types against their schema, keeps the manifest index unique, +checks that `contractId` and `appContractId` name existing contracts, and bills the writer. The +login key's scope, lifetime and budget are enforced by the identity key itself once registered. + +What Platform does not check: + +- that a manifest's owner owns the app contract (wallets check `$ownerId` against the + contract's owner); +- the dependency between `authBoundsKind` and `authBoundsId` / `authBoundsDocType`: the schema + admits a kind of `1`, `2` or `3` without an id, a kind of `2` without a document type, and an + id or document type beside a kind of `0`. Wallets must refuse a manifest whose bounds fields + do not match its kind; +- the layout of `encBindings`; +- whether a response belongs to a real request, or was written by the wallet the request was + made to. The app authenticates every response by decrypting it. + +Those are the wallet's and the app's to verify, and all are cheap because the indexes let them +ask for exactly the documents they expect. + +## Activation + +The contract activates with protocol version 14. A chain born at protocol version 14 or later +registers it at genesis alongside the other system contracts; a chain upgrading from protocol +version 13 receives it from `transition_to_version_14` on the first block of the new version. +Below protocol version 14 the contract does not exist in state, and the system contract cache +reports it absent so that a lookup is billed exactly as it would be on a node that has not +upgraded. diff --git a/package.json b/package.json index fc105333e0d..0b507e46efc 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:dpns-contract": "ultra -r --filter \"packages/@(dpns-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:token-history-contract": "ultra -r --filter \"packages/@(token-history-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "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: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", @@ -77,6 +78,7 @@ "packages/token-history-contract", "packages/document-history-contract", "packages/keyword-search-contract", + "packages/app-connect-contract", "packages/wasm-drive-verify", "packages/wasm-sdk", "packages/js-evo-sdk" diff --git a/packages/app-connect-contract/.mocharc.yml b/packages/app-connect-contract/.mocharc.yml new file mode 100644 index 00000000000..164b941c1b6 --- /dev/null +++ b/packages/app-connect-contract/.mocharc.yml @@ -0,0 +1,2 @@ +require: test/bootstrap.js +recursive: true diff --git a/packages/app-connect-contract/Cargo.toml b/packages/app-connect-contract/Cargo.toml new file mode 100644 index 00000000000..73b6b8b96d1 --- /dev/null +++ b/packages/app-connect-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "app-connect-contract" +description = "App Connect data contract schema and tools. The App Connect contract carries the wallet-to-app login handshake: the wallet's encrypted key response and the app's manifest" +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/app-connect-contract/LICENSE b/packages/app-connect-contract/LICENSE new file mode 100644 index 00000000000..3be95833750 --- /dev/null +++ b/packages/app-connect-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/app-connect-contract/README.md b/packages/app-connect-contract/README.md new file mode 100644 index 00000000000..9e51a931f73 --- /dev/null +++ b/packages/app-connect-contract/README.md @@ -0,0 +1,46 @@ +# App Connect Contract + +[![Build Status](https://github.com/dashpay/platform/actions/workflows/release.yml/badge.svg)](https://github.com/dashpay/platform/actions/workflows/release.yml) + +System data contract for the wallet-to-app login handshake (DashPay Connect). +It gives the two halves of a login one well-known contract id on every network: + +- `loginKeyResponse`: written by a wallet after the user approves an app's + `connect` request. Carries the app's ephemeral public key hash (the request + id), the wallet's ephemeral public key, and the session key material + encrypted to the app. Found by the app through the + `(contractId, appEphemeralPubKeyHash)` index, which is deliberately not + unique (the request id is public, so uniqueness would let anyone block the + wallet's write); the app authenticates each candidate by decrypting it. The + wallet keeps its response's document id locally and replaces it on re-login. +- `appManifest`: published once by the owner of an app's data contract. Names + the app, states the contract bounds its login key must carry, the session + lifetime and budget it asks for, and the encryption key bindings it needs, + packed as fixed 96-byte records in `encBindings`. Wallets look it up by + `($ownerId, appContractId)`, so only the contract's owner can publish the + manifest for it. + +Both document types are created by ordinary identities +(`creationRestrictionMode: 0`), mutable and deletable. The contract activates +with protocol version 14. See `docs/protocol/app-connect.md` for the login +flow. + +## Table of Contents + +- [Install](#install) +- [Contributing](#contributing) +- [License](#license) + +## Install + +```sh +npm install @dashevo/app-connect-contract +``` + +## Contributing + +Feel free to dive in! [Open an issue](https://github.com/dashpay/platform/issues/new/choose) or submit PRs. + +## License + +[MIT](LICENSE) © Dash Core Group, Inc. diff --git a/packages/app-connect-contract/eslint.config.mjs b/packages/app-connect-contract/eslint.config.mjs new file mode 100644 index 00000000000..cdf50e57d0d --- /dev/null +++ b/packages/app-connect-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/app-connect-contract/lib/systemIds.js b/packages/app-connect-contract/lib/systemIds.js new file mode 100644 index 00000000000..8fd6cbcd21b --- /dev/null +++ b/packages/app-connect-contract/lib/systemIds.js @@ -0,0 +1,4 @@ +module.exports = { + ownerId: '11111111111111111111111111111111', + contractId: 'H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ', +}; diff --git a/packages/app-connect-contract/package.json b/packages/app-connect-contract/package.json new file mode 100644 index 00000000000..d55a9fa63a4 --- /dev/null +++ b/packages/app-connect-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/app-connect-contract", + "version": "4.2.0-beta.2", + "description": "A contract for the wallet-to-app login handshake: encrypted key responses and app manifests", + "scripts": { + "lint": "eslint .", + "test": "yarn run test:unit", + "test:unit": "mocha 'test/unit/**/*.spec.js'" + }, + "contributors": [ + { + "name": "Pasta", + "email": "pasta@dashboost.org", + "url": "https://github.com/PastaPastaPasta" + } + ], + "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/app-connect-contract/schema/v1/app-connect-contract-documents.json b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json new file mode 100644 index 00000000000..6d7251f0356 --- /dev/null +++ b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json @@ -0,0 +1,164 @@ +{ + "loginKeyResponse": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 0, + "indices": [ + { + "name": "byContractAndEphemeralKey", + "properties": [ + { + "contractId": "asc" + }, + { + "appEphemeralPubKeyHash": "asc" + } + ] + } + ], + "properties": { + "contractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract" + }, + "description": "The data contract of the app the login was approved for", + "position": 0 + }, + "appEphemeralPubKeyHash": { + "type": "array", + "byteArray": true, + "minItems": 20, + "maxItems": 20, + "description": "hash160 of the ephemeral public key the app put in its connect request; identifies the request", + "position": 1 + }, + "walletEphemeralPubKey": { + "type": "array", + "byteArray": true, + "minItems": 33, + "maxItems": 33, + "description": "The wallet's compressed ephemeral public key; the app derives the shared secret from it and its own ephemeral private key", + "position": 2 + }, + "encryptedPayload": { + "type": "array", + "byteArray": true, + "minItems": 60, + "maxItems": 284, + "description": "The session key material encrypted to the app: a 28-byte envelope followed by 32 bytes per key, one to eight keys", + "position": 3 + } + }, + "required": [ + "contractId", + "appEphemeralPubKeyHash", + "walletEphemeralPubKey", + "encryptedPayload" + ], + "description": "A wallet's answer to an app's connect request, found by the app through the request's ephemeral key hash. The index is deliberately not unique: the request id is public, so a unique index without the owner would let anyone block the wallet's write; the app instead authenticates every candidate by decrypting it. A wallet replaces its own response on re-login.", + "additionalProperties": false + }, + "appManifest": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 0, + "indices": [ + { + "name": "byOwnerAndApp", + "properties": [ + { + "$ownerId": "asc" + }, + { + "appContractId": "asc" + } + ], + "unique": true + } + ], + "properties": { + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract" + }, + "description": "The app's data contract; wallets only trust a manifest whose owner is this contract's owner", + "position": 0 + }, + "name": { + "type": "string", + "maxLength": 64, + "description": "The app's display name, shown on the wallet's approval sheet", + "position": 1 + }, + "url": { + "type": "string", + "maxLength": 256, + "description": "The app's URL, shown on the wallet's approval sheet", + "position": 2 + }, + "authBoundsKind": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "description": "The contract bounds the login key must carry: 0 none, 1 the contract in authBoundsId, 2 the document type authBoundsDocType of the contract in authBoundsId, 3 the contract group in authBoundsId", + "position": 3 + }, + "authBoundsId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "description": "The contract or contract group id the login key is bound to; absent when authBoundsKind is 0", + "position": 4 + }, + "authBoundsDocType": { + "type": "string", + "maxLength": 64, + "description": "The document type name the login key is bound to; present only when authBoundsKind is 2", + "position": 5 + }, + "sessionSeconds": { + "type": "integer", + "minimum": 0, + "description": "The login key lifetime the app asks for, in seconds; the wallet may grant less", + "position": 6 + }, + "sessionBudget": { + "type": "integer", + "minimum": 0, + "description": "The login key budget the app asks for, in credits; the wallet may grant less", + "position": 7 + }, + "encBindings": { + "type": "array", + "byteArray": true, + "minItems": 0, + "maxItems": 768, + "description": "Zero to eight fixed 96-byte records, each a contract id (32 bytes), a purpose mask (1 byte: bit 0 ENCRYPTION, bit 1 DECRYPTION) and a zero-padded document type name (63 bytes, all zero for a contract-level binding), naming where the app needs encryption keys bound", + "position": 8 + } + }, + "required": [ + "appContractId", + "name", + "url", + "authBoundsKind", + "sessionSeconds", + "sessionBudget" + ], + "description": "An app's published identity and requirements for the connect handshake, created by the owner of the app's data contract. The bounds are requirements; the session lifetime and budget are requests that cap what the wallet grants.", + "additionalProperties": false + } +} diff --git a/packages/app-connect-contract/src/error.rs b/packages/app-connect-contract/src/error.rs new file mode 100644 index 00000000000..d01bbcc91cf --- /dev/null +++ b/packages/app-connect-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/app-connect-contract/src/lib.rs b/packages/app-connect-contract/src/lib.rs new file mode 100644 index 00000000000..9d985107cf2 --- /dev/null +++ b/packages/app-connect-contract/src/lib.rs @@ -0,0 +1,63 @@ +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] = [ + 239, 150, 14, 165, 105, 114, 235, 173, 190, 248, 162, 126, 247, 218, 92, 129, 255, 75, 179, + 138, 2, 150, 151, 69, 126, 36, 218, 66, 183, 155, 84, 183, +]; + +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)); +pub fn load_definitions(platform_version: &PlatformVersion) -> Result, Error> { + match platform_version.system_data_contracts.app_connect { + 1 => Ok(None), + version => Err(Error::UnknownVersionMismatch { + method: "app_connect_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.app_connect { + 1 => v1::load_documents_schemas(), + version => Err(Error::UnknownVersionMismatch { + method: "app_connect_contract::load_documents_schemas".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +#[cfg(test)] +mod tests { + use base58::FromBase58; + + use super::*; + + #[test] + /// Ensure that the ID constant matches the expected value + /// and that it can be encoded to base58 correctly. + fn test_id() { + assert_eq!( + ID, + Identifier(IdentifierBytes32(ID_BYTES)), + "ID should match the expected value" + ); + + let base58_decoded = "H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ" + .from_base58() + .unwrap(); + assert_eq!( + base58_decoded, ID_BYTES, + "ID should match the base58 decoded value" + ); + } +} diff --git a/packages/app-connect-contract/src/v1/mod.rs b/packages/app-connect-contract/src/v1/mod.rs new file mode 100644 index 00000000000..e7dd50e714a --- /dev/null +++ b/packages/app-connect-contract/src/v1/mod.rs @@ -0,0 +1,84 @@ +use crate::Error; +use serde_json::Value; + +pub mod document_types { + pub mod login_key_response { + pub const NAME: &str = "loginKeyResponse"; + + pub mod properties { + pub const CONTRACT_ID: &str = "contractId"; + pub const APP_EPHEMERAL_PUB_KEY_HASH: &str = "appEphemeralPubKeyHash"; + pub const WALLET_EPHEMERAL_PUB_KEY: &str = "walletEphemeralPubKey"; + pub const ENCRYPTED_PAYLOAD: &str = "encryptedPayload"; + } + + pub mod indexes { + pub const BY_CONTRACT_AND_EPHEMERAL_KEY: &str = "byContractAndEphemeralKey"; + } + } + + pub mod app_manifest { + pub const NAME: &str = "appManifest"; + + pub mod properties { + pub const APP_CONTRACT_ID: &str = "appContractId"; + pub const APP_NAME: &str = "name"; + pub const URL: &str = "url"; + pub const AUTH_BOUNDS_KIND: &str = "authBoundsKind"; + pub const AUTH_BOUNDS_ID: &str = "authBoundsId"; + pub const AUTH_BOUNDS_DOC_TYPE: &str = "authBoundsDocType"; + pub const SESSION_SECONDS: &str = "sessionSeconds"; + pub const SESSION_BUDGET: &str = "sessionBudget"; + pub const ENC_BINDINGS: &str = "encBindings"; + } + + pub mod indexes { + pub const BY_OWNER_AND_APP: &str = "byOwnerAndApp"; + } + + /// Values of the `authBoundsKind` property: the contract bounds the app asks the + /// wallet to put on the login key. + pub mod auth_bounds_kind { + /// No bounds: the key can act anywhere; expiry and budget still apply. + pub const NONE: u8 = 0; + /// Bound to the contract named by `authBoundsId`. + pub const CONTRACT: u8 = 1; + /// Bound to the document type `authBoundsDocType` of the contract named by + /// `authBoundsId`. + pub const CONTRACT_DOCUMENT_TYPE: u8 = 2; + /// Bound to the contract group named by `authBoundsId`. + pub const CONTRACT_GROUP: u8 = 3; + } + + /// Layout of the packed `encBindings` byte array: zero to + /// [`MAX_RECORDS`](enc_bindings::MAX_RECORDS) fixed-size records, each naming a + /// contract (or one of its document types) and which encryption key purposes the + /// app wants bound there. + pub mod enc_bindings { + /// Size of one record: contract id, purpose mask, document type name. + pub const RECORD_SIZE: usize = 96; + /// Maximum number of records, so the array is at most 768 bytes. + pub const MAX_RECORDS: usize = 8; + /// Byte offset and length of the contract id within a record. + pub const CONTRACT_ID_OFFSET: usize = 0; + pub const CONTRACT_ID_SIZE: usize = 32; + /// Byte offset of the one-byte purpose mask within a record. + pub const PURPOSE_MASK_OFFSET: usize = 32; + /// Purpose mask bit asking for an ENCRYPTION key bound there. + pub const PURPOSE_ENCRYPTION: u8 = 0b01; + /// Purpose mask bit asking for a DECRYPTION key bound there. + pub const PURPOSE_DECRYPTION: u8 = 0b10; + /// Byte offset and length of the zero-padded document type name within a + /// record; all zero for a contract-level binding. + pub const DOCUMENT_TYPE_NAME_OFFSET: usize = 33; + pub const DOCUMENT_TYPE_NAME_SIZE: usize = 63; + } + } +} + +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!( + "../../schema/v1/app-connect-contract-documents.json" + )) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/app-connect-contract/test/bootstrap.js b/packages/app-connect-contract/test/bootstrap.js new file mode 100644 index 00000000000..7af04f464d7 --- /dev/null +++ b/packages/app-connect-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/app-connect-contract/test/unit/appConnectContract.spec.js b/packages/app-connect-contract/test/unit/appConnectContract.spec.js new file mode 100644 index 00000000000..b2c38da0505 --- /dev/null +++ b/packages/app-connect-contract/test/unit/appConnectContract.spec.js @@ -0,0 +1,508 @@ +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 appConnectContractDocumentsSchema = require('../../schema/v1/app-connect-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('App Connect Contract', () => { + let dpp; + let dataContract; + let identityId; + + beforeEach(async () => { + dpp = new DashPlatformProtocol( + { generate: () => crypto.randomBytes(32) }, + ); + + identityId = await generateRandomIdentifier(); + + dataContract = dpp.dataContract.create( + identityId, + BigInt(1), + appConnectContractDocumentsSchema, + ); + }); + + it('should have a valid contract definition', async () => { + expect(() => dpp.dataContract.create( + identityId, + BigInt(1), + appConnectContractDocumentsSchema, + )) + .to + .not + .throw(); + }); + + describe('documents', () => { + describe('loginKeyResponse', () => { + let rawLoginKeyResponseDocument; + + beforeEach(() => { + rawLoginKeyResponseDocument = { + contractId: crypto.randomBytes(32), + appEphemeralPubKeyHash: crypto.randomBytes(20), + walletEphemeralPubKey: crypto.randomBytes(33), + encryptedPayload: crypto.randomBytes(60), + }; + }); + + describe('contractId', () => { + it('should be defined', async () => { + delete rawLoginKeyResponseDocument.contractId; + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('contractId'); + }); + + it('should be exactly 32 bytes long', async () => { + rawLoginKeyResponseDocument.contractId = crypto.randomBytes(31); + + // Identifier-typed byte arrays are converted at document creation, + // so a wrong-length value throws there instead of surfacing as a + // JSON-schema validation error. + let error; + try { + dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + } catch (e) { + error = e; + } + + expect(error).to.exist(); + expect(String(error)).to.contain('not 32 bytes long'); + }); + }); + + describe('appEphemeralPubKeyHash', () => { + it('should be defined', async () => { + delete rawLoginKeyResponseDocument.appEphemeralPubKeyHash; + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('appEphemeralPubKeyHash'); + }); + + it('should be not shorter than 20 bytes', async () => { + rawLoginKeyResponseDocument.appEphemeralPubKeyHash = crypto.randomBytes(19); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minItems'); + }); + + it('should be not longer than 20 bytes', async () => { + rawLoginKeyResponseDocument.appEphemeralPubKeyHash = crypto.randomBytes(21); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxItems'); + }); + }); + + describe('walletEphemeralPubKey', () => { + it('should be defined', async () => { + delete rawLoginKeyResponseDocument.walletEphemeralPubKey; + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('walletEphemeralPubKey'); + }); + + it('should be not shorter than 33 bytes', async () => { + rawLoginKeyResponseDocument.walletEphemeralPubKey = crypto.randomBytes(32); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minItems'); + }); + + it('should be not longer than 33 bytes', async () => { + rawLoginKeyResponseDocument.walletEphemeralPubKey = crypto.randomBytes(34); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxItems'); + }); + }); + + describe('encryptedPayload', () => { + it('should be defined', async () => { + delete rawLoginKeyResponseDocument.encryptedPayload; + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('encryptedPayload'); + }); + + it('should be not shorter than 60 bytes', async () => { + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(59); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minItems'); + }); + + it('should be not longer than 284 bytes', async () => { + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(285); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxItems'); + }); + + it('should accept eight keys', async () => { + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(284); + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + }); + + it('should not have additional properties', async () => { + rawLoginKeyResponseDocument.someOtherProperty = 42; + + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['someOtherProperty']); + }); + + it('should be valid', async () => { + const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + }); + + describe('appManifest', () => { + let rawAppManifestDocument; + + beforeEach(() => { + rawAppManifestDocument = { + appContractId: crypto.randomBytes(32), + name: 'Yappr', + url: 'https://yap.pr', + authBoundsKind: 3, + authBoundsId: crypto.randomBytes(32), + sessionSeconds: 604800, + sessionBudget: 10000000000, + encBindings: crypto.randomBytes(96 * 4), + }; + }); + + describe('appContractId', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.appContractId; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('appContractId'); + }); + + it('should be exactly 32 bytes long', async () => { + rawAppManifestDocument.appContractId = crypto.randomBytes(33); + + let error; + try { + dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + } catch (e) { + error = e; + } + + expect(error).to.exist(); + expect(String(error)).to.contain('not 32 bytes long'); + }); + }); + + describe('name', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.name; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('name'); + }); + + it('should be not longer than 64 characters', async () => { + rawAppManifestDocument.name = 'a'.repeat(65); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('url', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.url; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('url'); + }); + + it('should be not longer than 256 characters', async () => { + rawAppManifestDocument.url = 'a'.repeat(257); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('authBoundsKind', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.authBoundsKind; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('authBoundsKind'); + }); + + it('should be not greater than 3', async () => { + rawAppManifestDocument.authBoundsKind = 4; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maximum'); + }); + + it('should be not less than 0', async () => { + rawAppManifestDocument.authBoundsKind = -1; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minimum'); + }); + }); + + describe('authBoundsId', () => { + it('should be optional', async () => { + delete rawAppManifestDocument.authBoundsId; + rawAppManifestDocument.authBoundsKind = 0; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + + it('should be not shorter than 32 bytes', async () => { + rawAppManifestDocument.authBoundsId = crypto.randomBytes(31); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minItems'); + }); + + it('should be not longer than 32 bytes', async () => { + rawAppManifestDocument.authBoundsId = crypto.randomBytes(33); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxItems'); + }); + }); + + describe('authBoundsDocType', () => { + it('should be optional', async () => { + rawAppManifestDocument.authBoundsKind = 2; + rawAppManifestDocument.authBoundsDocType = 'post'; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + + it('should be not longer than 64 characters', async () => { + rawAppManifestDocument.authBoundsDocType = 'a'.repeat(65); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('sessionSeconds', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.sessionSeconds; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('sessionSeconds'); + }); + + it('should be not less than 0', async () => { + rawAppManifestDocument.sessionSeconds = -1; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minimum'); + }); + }); + + describe('sessionBudget', () => { + it('should be defined', async () => { + delete rawAppManifestDocument.sessionBudget; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('sessionBudget'); + }); + + it('should be not less than 0', async () => { + rawAppManifestDocument.sessionBudget = -1; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('minimum'); + }); + }); + + describe('encBindings', () => { + it('should be optional', async () => { + delete rawAppManifestDocument.encBindings; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + + it('should accept an empty array', async () => { + rawAppManifestDocument.encBindings = Buffer.alloc(0); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + + it('should accept eight records', async () => { + rawAppManifestDocument.encBindings = crypto.randomBytes(96 * 8); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + + it('should be not longer than 768 bytes', async () => { + rawAppManifestDocument.encBindings = crypto.randomBytes(769); + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('maxItems'); + }); + }); + + it('should not have additional properties', async () => { + rawAppManifestDocument.someOtherProperty = 42; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + const error = expectJsonSchemaError(validationResult); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['someOtherProperty']); + }); + + it('should be valid', async () => { + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + + expect(validationResult.isValid()).to.be.true(); + }); + }); + }); +}); diff --git a/packages/data-contracts/Cargo.toml b/packages/data-contracts/Cargo.toml index da907fd418c..0f331adf20a 100644 --- a/packages/data-contracts/Cargo.toml +++ b/packages/data-contracts/Cargo.toml @@ -18,6 +18,7 @@ all-contracts = [ "token-history", "keyword-search", "document-history", + "app-connect", ] # Individual contract features @@ -29,6 +30,7 @@ wallet-utils = ["dep:wallet-utils-contract"] token-history = ["dep:token-history-contract"] keyword-search = ["dep:keyword-search-contract"] document-history = ["dep:document-history-contract"] +app-connect = ["dep:app-connect-contract"] [dependencies] thiserror = "2.0.12" @@ -43,3 +45,4 @@ wallet-utils-contract = { path = "../wallet-utils-contract", optional = true } token-history-contract = { path = "../token-history-contract", optional = true } 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 } diff --git a/packages/data-contracts/src/error.rs b/packages/data-contracts/src/error.rs index f5a89053bca..ce469e36239 100644 --- a/packages/data-contracts/src/error.rs +++ b/packages/data-contracts/src/error.rs @@ -165,3 +165,21 @@ impl From for Error { } } } + +#[cfg(feature = "app-connect")] +impl From for Error { + fn from(e: app_connect_contract::Error) -> Self { + match e { + app_connect_contract::Error::UnknownVersionMismatch { + method, + known_versions, + received, + } => Error::UnknownVersionMismatch { + method, + known_versions, + received, + }, + app_connect_contract::Error::InvalidSchemaJson(e) => Error::InvalidSchemaJson(e), + } + } +} diff --git a/packages/data-contracts/src/lib.rs b/packages/data-contracts/src/lib.rs index 0172c6205ab..26c824d014c 100644 --- a/packages/data-contracts/src/lib.rs +++ b/packages/data-contracts/src/lib.rs @@ -4,6 +4,9 @@ use serde_json::Value; use crate::error::Error; +#[cfg(feature = "app-connect")] +pub use app_connect_contract; + #[cfg(feature = "dashpay")] pub use dashpay_contract; @@ -46,6 +49,7 @@ pub enum SystemDataContract { TokenHistory = 6, KeywordSearch = 7, DocumentHistory = 8, + AppConnect = 9, } pub struct DataContractSource { @@ -62,7 +66,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; 9] = [ + pub const ALL: [SystemDataContract; 10] = [ SystemDataContract::Withdrawals, SystemDataContract::MasternodeRewards, SystemDataContract::FeatureFlags, @@ -72,6 +76,7 @@ impl SystemDataContract { SystemDataContract::TokenHistory, SystemDataContract::KeywordSearch, SystemDataContract::DocumentHistory, + SystemDataContract::AppConnect, ]; /// A new variant must also be added to [`SystemDataContract::ALL`]; this match is where the @@ -149,6 +154,14 @@ impl SystemDataContract { 88, 18, 140, 208, 179, 231, 242, 57, 225, 203, 4, 210, 245, 95, 136, 92, 160, 167, 112, 118, 173, 238, 83, 62, 234, 230, 222, 16, 231, 30, 99, 98, ], + + #[cfg(feature = "app-connect")] + SystemDataContract::AppConnect => app_connect_contract::ID_BYTES, + #[cfg(not(feature = "app-connect"))] + SystemDataContract::AppConnect => [ + 239, 150, 14, 165, 105, 114, 235, 173, 190, 248, 162, 126, 247, 218, 92, 129, 255, + 75, 179, 138, 2, 150, 151, 69, 126, 36, 218, 66, 183, 155, 84, 183, + ], }; Identifier::new(bytes) } @@ -258,6 +271,17 @@ impl SystemDataContract { SystemDataContract::DocumentHistory => { Err(Error::ContractNotIncluded("document-history")) } + + #[cfg(feature = "app-connect")] + SystemDataContract::AppConnect => Ok(DataContractSource { + id_bytes: app_connect_contract::ID_BYTES, + owner_id_bytes: app_connect_contract::OWNER_ID_BYTES, + version: platform_version.system_data_contracts.app_connect as u32, + definitions: app_connect_contract::load_definitions(platform_version)?, + document_schemas: app_connect_contract::load_documents_schemas(platform_version)?, + }), + #[cfg(not(feature = "app-connect"))] + SystemDataContract::AppConnect => Err(Error::ContractNotIncluded("app-connect")), } } } diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index bf9bba16c3c..d98e26f03b6 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -268,6 +268,7 @@ all-system_contracts = [ "token-history-contract", "keywords-contract", "document-history-contract", + "app-connect-contract", ] # Individual data contract features @@ -282,6 +283,7 @@ wallet-utils-contract = ["data-contracts", "data-contracts/wallet-utils"] token-history-contract = ["data-contracts", "data-contracts/token-history"] 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"] 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/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index e5504da4d21..2e248939f18 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -69,6 +69,11 @@ impl ConfigurationForSystemContract for SystemDataContract { config.set_sized_integer_types_enabled(true); Ok(config) } + SystemDataContract::AppConnect => { + let mut config = DataContractConfig::default_for_version(platform_version)?; + config.set_sized_integer_types_enabled(true); + Ok(config) + } } } } 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 0c867dcbaeb..b73b3a0e0b8 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 @@ -71,6 +71,16 @@ impl Platform { ); } + // The app-connect contract activates with protocol version 14, for the + // same reason: chains born earlier keep their historical genesis state + // and receive it from `transition_to_version_14` instead + if platform_version.protocol_version >= 14 { + system_data_contract_types.insert( + SystemDataContract::AppConnect, + system_data_contracts.load_app_connect(platform_version)?, + ); + } + for data_contract in system_data_contract_types.values() { self.register_system_data_contract_operations( data_contract, @@ -147,5 +157,55 @@ mod tests { "dc5b0d4be407428adda2315db7d782e64015cbe2d2b7df963f05622390dc3c9f" ) } + + /// The app-connect contract is part of the genesis state from protocol + /// version 14 on and absent from the genesis state of every earlier + /// version, which a replaying node must still reproduce byte for byte. + #[test] + pub fn should_register_the_app_connect_contract_only_from_protocol_version_14() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contracts::SystemDataContract; + + let app_connect_id = SystemDataContract::AppConnect.id(); + + for (initial_protocol_version, expected) in [(13, false), (14, true)] { + let platform_version = PlatformVersion::get(initial_protocol_version) + .expect("expected a supported platform 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( + app_connect_id.to_buffer(), + None, + None, + None, + platform_version, + ) + .value + .expect("expected to query the app-connect contract"); + + assert_eq!( + stored.is_some(), + expected, + "app-connect contract presence in a genesis state born at protocol version {initial_protocol_version}" + ); + + if let Some(stored) = stored { + assert_eq!(stored.contract.id(), app_connect_id); + assert!(stored + .contract + .document_type_for_name("loginKeyResponse") + .is_ok()); + assert!(stored + .contract + .document_type_for_name("appManifest") + .is_ok()); + } + } + } } } 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 28ad1c40e4f..b29235d76d2 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 @@ -695,8 +695,10 @@ impl Platform { /// When transitioning to version 14 we re-store the DashPay contract whose /// v2 schema adds the optional public payment address fields to the - /// `profile` document type (DIP-33), and the withdrawals contract whose v2 - /// schema admits the terminal FAILED value of the `status` property. + /// `profile` document type (DIP-33), the withdrawals contract whose v2 + /// schema admits the terminal FAILED value of the `status` property, and + /// register the app-connect contract that carries the wallet-to-app login + /// handshake. fn transition_to_version_14( &self, block_info: &BlockInfo, @@ -730,6 +732,20 @@ impl Platform { platform_version, )?; + // App-connect contract: the wallet's encrypted login key response and the app's + // manifest get one system contract id on every network from this version. Fresh + // chains register it at genesis (`create_genesis_state` v1). + let app_connect_contract = + load_system_data_contract(SystemDataContract::AppConnect, platform_version)?; + + self.drive.insert_contract( + &app_connect_contract, + *block_info, + true, + Some(transaction), + platform_version, + )?; + // Total credits history under the withdrawals tree: the daily withdrawal limit becomes // a share of the total credits Platform held a day ago, recorded here every block. self.drive.grove_insert_if_not_exists( @@ -1219,6 +1235,105 @@ mod tests { assert!(profile.iter().any(|p| p == "shieldedAddress")); } + #[test] + fn test_transition_to_version_14_inserts_app_connect_contract() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + // A chain born at protocol version 13 has no app-connect contract: it + // is neither in that genesis state nor active for the system contract + // cache. + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version_13 = PlatformVersion::get(13).expect("expected platform version 13"); + let platform_version = PlatformVersion::get(14).expect("expected platform version 14"); + let app_connect_id = SystemDataContract::AppConnect.id(); + + let transaction = platform.drive.grove.start_transaction(); + + assert!( + platform + .drive + .fetch_contract( + app_connect_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version_13, + ) + .value + .expect("expected to query the app-connect contract") + .is_none(), + "the app-connect contract must not exist before transition_to_version_14" + ); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(app_connect_id, platform_version_13) + .expect("expected the pre-activation lookup to succeed") + .is_none()); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + + platform + .transition_to_version_14(&block_info, &transaction, platform_version) + .expect("expected the transition to succeed"); + + let stored = platform + .drive + .fetch_contract( + app_connect_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version, + ) + .value + .expect("expected to fetch the app-connect contract") + .expect("the app-connect contract must exist after transition_to_version_14"); + + assert_eq!(stored.contract.id(), app_connect_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert!(stored + .contract + .document_type_for_name("loginKeyResponse") + .is_ok()); + assert!(stored + .contract + .document_type_for_name("appManifest") + .is_ok()); + + // Stored beside its version item, like every contract from this version on. + assert_eq!( + platform + .drive + .fetch_contract_version( + app_connect_id.to_buffer(), + Some(&transaction), + platform_version + ) + .expect("expected to read the version item"), + Some(stored.contract.version()), + "the app-connect contract has its version item after the transition" + ); + + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(app_connect_id, platform_version) + .expect("expected the post-activation lookup to succeed") + .is_some()); + } + /// Reads the `status` enum of the stored withdrawals contract's `withdrawal` document /// type, the contract-level record of which statuses a withdrawal may carry. fn stored_withdrawal_status_enum( diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index 2d2655be964..0a255358c4e 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -159,6 +159,14 @@ impl SystemDataContracts { self.load(SystemDataContract::DocumentHistory, platform_version) } + /// Returns the app-connect contract materialized for `platform_version`. + pub fn load_app_connect( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.load(SystemDataContract::AppConnect, platform_version) + } + /// Returns the system contract whose deterministic identifier matches `id`, materialized /// for `platform_version`. /// @@ -197,6 +205,8 @@ impl SystemDataContracts { SystemDataContract::TokenHistory | SystemDataContract::KeywordSearch => 9, // Written to state by the transition to protocol version 13. SystemDataContract::DocumentHistory => 13, + // Written to state by the transition to protocol version 14. + SystemDataContract::AppConnect => 14, // 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), @@ -399,6 +409,20 @@ mod tests { .is_some()); } + #[test] + fn app_connect_cache_respects_its_activation_version() { + let contracts = SystemDataContracts::new(); + + assert!(contracts + .find_by_id(SystemDataContract::AppConnect.id(), platform_version(13)) + .expect("expected the pre-activation lookup to succeed") + .is_none()); + assert!(contracts + .find_by_id(SystemDataContract::AppConnect.id(), platform_version(14)) + .expect("expected the v14 lookup to succeed") + .is_some()); + } + fn memoized_protocol_versions(contracts: &SystemDataContracts) -> Vec { let materialized = contracts.materialized.load(); let mut protocol_versions: Vec = materialized 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 1f01823d67a..62b8a6ea477 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 @@ -14,4 +14,5 @@ pub struct SystemDataContractVersions { pub token_history: FeatureVersion, pub keyword_search: FeatureVersion, pub document_history: FeatureVersion, + pub app_connect: 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 5e814cc4b78..5759ca91273 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 @@ -10,4 +10,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V1: SystemDataContractVersions = token_history: 1, keyword_search: 1, document_history: 1, + app_connect: 1, }; 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 9b87b90e94b..3c7877fe426 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 @@ -15,4 +15,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V2: SystemDataContractVersions = token_history: 1, keyword_search: 1, document_history: 1, + app_connect: 1, }; 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 3261a215403..4bd6c11c210 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -10,6 +10,12 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // `distributionType`, written for once-per-identity distribution claims. // v2 (dashpay: 1, withdrawals: 1, token_history: 1) remains for // PROTOCOL_VERSION_13 chain replay. +// +// The app-connect contract (app_connect: 1) also activates with +// PROTOCOL_VERSION_14: it is registered at genesis from that version on and +// inserted by `transition_to_version_14` on chains upgrading from 13. Its +// feature version is listed in the earlier tables only because the struct has +// no optional fields; before 14 the contract is never loaded or served. pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, @@ -20,4 +26,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = token_history: 2, keyword_search: 1, document_history: 1, + app_connect: 1, }; diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index a25212abc97..a95d87e5d5f 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -24,6 +24,7 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", "token-history-contract", "keywords-contract", "document-history-contract", + "app-connect-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 365e8e6e167..69d488e11b9 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -42,6 +42,7 @@ all-system-contracts = [ "token-history-contract", "keywords-contract", "document-history-contract", + "app-connect-contract", ] # Individual contract features - these enable specific contracts in DPP @@ -52,6 +53,7 @@ wallet-utils-contract = ["dpp/wallet-utils-contract"] token-history-contract = ["dpp/token-history-contract"] keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] +app-connect-contract = ["dpp/app-connect-contract"] [target.'cfg(not(target_os = "android"))'.dependencies] reqwest = { version = "0.12", features = ["json"] } diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index e88bcd650b8..28263e6045d 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -18,6 +18,7 @@ use dpp::data_contract::TokenConfiguration; feature = "token-history-contract", feature = "keywords-contract", feature = "document-history-contract", + feature = "app-connect-contract", feature = "all-system-contracts" ))] use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -781,6 +782,7 @@ impl ContextProvider for TrustedHttpContextProvider { feature = "token-history-contract", feature = "keywords-contract", feature = "document-history-contract", + feature = "app-connect-contract", feature = "all-system-contracts" ))] { @@ -886,6 +888,18 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::AppConnect.id() { + return load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load AppConnect contract: {}", + e + )) + }); + } } // If not found in known contracts or system contracts, delegate to fallback provider if available diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 30e6a1bae7b..0a75a9f36c6 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -143,6 +143,7 @@ wallet-utils-contract = ["dpp/wallet-utils-contract"] token-history-contract = ["dpp/token-history-contract"] keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] +app-connect-contract = ["dpp/app-connect-contract"] token_reward_explanations = ["dpp/token-reward-explanations"] diff --git a/yarn.lock b/yarn.lock index 659b772ef72..9bf457de492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1483,6 +1483,20 @@ __metadata: languageName: node linkType: hard +"@dashevo/app-connect-contract@workspace:packages/app-connect-contract": + version: 0.0.0-use.local + resolution: "@dashevo/app-connect-contract@workspace:packages/app-connect-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/bench-suite@workspace:packages/bench-suite": version: 0.0.0-use.local resolution: "@dashevo/bench-suite@workspace:packages/bench-suite" From 7251bbcf1a8c3d6356f86e008132b9f7e146111a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 19 Sep 2026 10:16:53 -0500 Subject: [PATCH 2/6] fix(app-connect): address review Genesis: restore create_genesis_state v1 byte-identical to v4.2-dev and add a v2 generation, selected only by protocol 14's DRIVE_ABCI_METHOD_VERSIONS_V10, where DocumentHistory and AppConnect registration are unconditional; the genesis test moves to v2 and runs through the dispatcher on both sides of the gate. Schema: raise encryptedPayload maxItems from 284 to 572 so the maximum grant (session key plus eight bindings with both purposes, 17 keys) fits, with spec tests for 572 and 573 bytes. Docs: the session key is derived per request (leaf = request id), so the retry shortcut only applies to the identical request; ECDH to a public ephemeral key does not authenticate the sender, so the app verifies the granted keys against the response document's owner identity and a residual-risk note covers the observer case. v14.rs records the new contract in the protocol-14 snapshot. Co-Authored-By: Claude Fable 5.1 --- docs/protocol/app-connect.md | 37 ++-- .../v1/app-connect-contract-documents.json | 4 +- .../test/unit/appConnectContract.spec.js | 10 +- .../create_genesis_state/mod.rs | 10 +- .../create_genesis_state/v1/mod.rs | 60 ------ .../create_genesis_state/v2/mod.rs | 186 ++++++++++++++++++ .../v0/mod.rs | 2 +- .../drive_abci_method_versions/v10.rs | 2 +- .../rs-platform-version/src/version/v14.rs | 13 +- 9 files changed, 241 insertions(+), 83 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v2/mod.rs diff --git a/docs/protocol/app-connect.md b/docs/protocol/app-connect.md index d6d48bebaae..6a5a3eb973a 100644 --- a/docs/protocol/app-connect.md +++ b/docs/protocol/app-connect.md @@ -36,7 +36,7 @@ credits per document for a constraint the wallet enforces itself. | `contractId` | identifier, `refersTo: contract` | The app's data contract. The reference means the contract must exist when the document is written. | | `appEphemeralPubKeyHash` | 20 bytes | `hash160` of the ephemeral public key the app put in its request. It identifies the request, and it is what the app polls for. | | `walletEphemeralPubKey` | 33 bytes | The wallet's compressed ephemeral public key. The app combines it with its own ephemeral private key to derive the shared secret. | -| `encryptedPayload` | 60 to 284 bytes | The private keys the wallet grants the app, encrypted to the shared secret: a 28-byte envelope followed by 32 bytes per key, one to eight keys. | +| `encryptedPayload` | 60 to 572 bytes | The private keys the wallet grants the app, encrypted to the shared secret: a 28-byte envelope followed by 32 bytes per key, one to seventeen keys (the session key plus up to eight bindings with both purposes). | All four are required. The single index, `byContractAndEphemeralKey` on `(contractId, appEphemeralPubKeyHash)`, lets the app fetch the answers to its request with a @@ -44,12 +44,14 @@ two-value equality query and a proof. The index is deliberately **not** unique. The app's ephemeral public key is public (it is in the QR code), so a unique index that does not include `$ownerId` would let any observer -pre-create a row under the request id and block the wallet's write. Instead the app -authenticates every candidate it gets back: the payload is AES-GCM, and its tag is computed over -additional data that binds `contractId`, `appEphemeralPubKeyHash` and `walletEphemeralPubKey`, -so only a row written by the party that holds the shared secret decrypts. A squatter's row fails -to decrypt, costs the squatter a document fee, and is ignored. Apps must therefore query by -both index values and try each result rather than assume there is exactly one. +pre-create a row under the request id and block the wallet's write. Instead the app checks +every candidate it gets back. The payload is AES-GCM, and its tag is computed over additional +data that binds `contractId`, `appEphemeralPubKeyHash` and `walletEphemeralPubKey`, so +decryption filters out rows not written by a party holding the shared secret. A row from a party +that did compute the secret (anyone who saw the QR can, since `e` is public) is caught by the +identity check in [The app](#the-app) below. Either way a squatter's row costs the squatter a +document fee and is ignored. Apps must therefore query by both index values and try each result +rather than assume there is exactly one. ### `appManifest` @@ -118,8 +120,9 @@ The app keeps the matching private key in memory until the answer arrives. 1. Fetches the app contract, then the manifest owned by the contract's owner whose `appContractId` matches. Either missing, the request is refused. 2. Lets the user choose an identity if the wallet holds more than one usable one. -3. Derives the login key for this app and identity. If a key with that hash is already on the - identity and not expired, the request is a retry and the wallet skips to step 6 with it. +3. Derives the session key for this request (its derivation leaf is the request id, so a + different `e` yields a different key). If that key is already on the identity and not + expired, the same request was already served: skip to step 6 with it. 4. For every `encBindings` record, checks the identity for the bound keys it asks for and plans to add the missing ones. 5. Shows the approval sheet: the app's name and URL, the key's lifetime and budget (the wallet's @@ -134,9 +137,19 @@ The app keeps the matching private key in memory until the answer arrives. Polls the `loginKeyResponse` documents whose `contractId` is its own contract and whose `appEphemeralPubKeyHash` is `hash160(e)`. For each result the app derives the shared secret from `walletEphemeralPubKey` and tries to decrypt the payload; the first one whose authentication tag -verifies is the wallet's answer, and the rest are discarded. It then confirms each key's public -half is live on the identity. It is logged in until the key expires or its budget runs out, at -which point it starts a new `connect`. +verifies is the candidate answer, and the rest are discarded. The app then reads `$ownerId` from +that document, verifies that each granted key's public half is a live key on that identity, and +treats that identity as the logged-in user. It is logged in until the key expires or its budget +runs out, at which point it starts a new `connect`. + +**Residual risk.** ECDH to a public ephemeral key does not authenticate the sender: an observer +of the QR code can compute the shared secret and answer the request with keys of their own +identity. The identity check bounds what that achieves. The observer's keys are live only on the +observer's identity, so the app is logged into the observer's account, never the user's; the +observer gains nothing about the user and has spent a document fee. That is the standard +exposure of any unauthenticated pairing. The wallet's approval sheet is the pairing step, and +the identity the app shows after login is the user's confirmation that it paired with the right +wallet. ### Signing outside the login key's scope diff --git a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json index 6d7251f0356..5b47dbab095 100644 --- a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json +++ b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json @@ -50,8 +50,8 @@ "type": "array", "byteArray": true, "minItems": 60, - "maxItems": 284, - "description": "The session key material encrypted to the app: a 28-byte envelope followed by 32 bytes per key, one to eight keys", + "maxItems": 572, + "description": "The session key material encrypted to the app: a 28-byte envelope followed by 32 bytes per key, one to seventeen keys (the session key plus up to eight encBindings records with both purposes)", "position": 3 } }, diff --git a/packages/app-connect-contract/test/unit/appConnectContract.spec.js b/packages/app-connect-contract/test/unit/appConnectContract.spec.js index b2c38da0505..c62a8ca4cab 100644 --- a/packages/app-connect-contract/test/unit/appConnectContract.spec.js +++ b/packages/app-connect-contract/test/unit/appConnectContract.spec.js @@ -186,8 +186,8 @@ describe('App Connect Contract', () => { expect(error.keyword).to.equal('minItems'); }); - it('should be not longer than 284 bytes', async () => { - rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(285); + it('should be not longer than 572 bytes', async () => { + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(573); const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); const validationResult = document.validate(dpp.protocolVersion); @@ -196,8 +196,10 @@ describe('App Connect Contract', () => { expect(error.keyword).to.equal('maxItems'); }); - it('should accept eight keys', async () => { - rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(284); + it('should accept the maximum grant of seventeen keys', async () => { + // Session key plus eight encBindings records asking for both + // purposes: 28-byte envelope + 17 * 32 = 572 bytes. + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(28 + (17 * 32)); const document = dpp.document.create(dataContract, identityId, 'loginKeyResponse', rawLoginKeyResponseDocument); const validationResult = document.validate(dpp.protocolVersion); diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/mod.rs index 7afeeeaf7c9..331d5550eee 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/mod.rs @@ -11,6 +11,7 @@ mod common; mod test; pub mod v0; pub mod v1; +pub mod v2; impl Platform { /// Creates trees and populates them with necessary identities, contracts and documents @@ -41,9 +42,16 @@ impl Platform { transaction, platform_version, ), + // V2 (protocol version 14 and later) also registers the app-connect contract + 2 => self.create_genesis_state_v2( + genesis_core_height, + genesis_time, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "create_genesis_state".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), }?; diff --git a/packages/rs-drive-abci/src/execution/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 b73b3a0e0b8..0c867dcbaeb 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 @@ -71,16 +71,6 @@ impl Platform { ); } - // The app-connect contract activates with protocol version 14, for the - // same reason: chains born earlier keep their historical genesis state - // and receive it from `transition_to_version_14` instead - if platform_version.protocol_version >= 14 { - system_data_contract_types.insert( - SystemDataContract::AppConnect, - system_data_contracts.load_app_connect(platform_version)?, - ); - } - for data_contract in system_data_contract_types.values() { self.register_system_data_contract_operations( data_contract, @@ -157,55 +147,5 @@ mod tests { "dc5b0d4be407428adda2315db7d782e64015cbe2d2b7df963f05622390dc3c9f" ) } - - /// The app-connect contract is part of the genesis state from protocol - /// version 14 on and absent from the genesis state of every earlier - /// version, which a replaying node must still reproduce byte for byte. - #[test] - pub fn should_register_the_app_connect_contract_only_from_protocol_version_14() { - use dpp::data_contract::accessors::v0::DataContractV0Getters; - use dpp::data_contracts::SystemDataContract; - - let app_connect_id = SystemDataContract::AppConnect.id(); - - for (initial_protocol_version, expected) in [(13, false), (14, true)] { - let platform_version = PlatformVersion::get(initial_protocol_version) - .expect("expected a supported platform 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( - app_connect_id.to_buffer(), - None, - None, - None, - platform_version, - ) - .value - .expect("expected to query the app-connect contract"); - - assert_eq!( - stored.is_some(), - expected, - "app-connect contract presence in a genesis state born at protocol version {initial_protocol_version}" - ); - - if let Some(stored) = stored { - assert_eq!(stored.contract.id(), app_connect_id); - assert!(stored - .contract - .document_type_for_name("loginKeyResponse") - .is_ok()); - assert!(stored - .contract - .document_type_for_name("appManifest") - .is_ok()); - } - } - } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v2/mod.rs new file mode 100644 index 00000000000..395d41ae786 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v2/mod.rs @@ -0,0 +1,186 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; + +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::PlatformVersion; +use drive::dpp::system_data_contracts::SystemDataContract; +use drive::query::TransactionArg; +use std::collections::BTreeMap; + +impl Platform { + /// Creates trees and populates them with necessary identities, contracts and documents. + /// + /// v2 is v1 (protocol versions 6 to 13) plus the app-connect contract, which activates with + /// protocol version 14. Both the document history contract (v1 registered it from protocol + /// version 13 behind a version check) and the app-connect contract are unconditional here: + /// this generation is only ever selected by the tables of protocol version 14 and later. + #[inline(always)] + pub(super) fn create_genesis_state_v2( + &self, + genesis_core_height: CoreBlockHeight, + genesis_time: TimestampMillis, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + //versioned call + self.drive + .create_initial_state_structure(transaction, platform_version)?; + + self.drive + .store_genesis_core_height(genesis_core_height, transaction, platform_version)?; + + let mut operations = vec![]; + + // Create system identities and contracts + + let system_data_contracts = &self.drive.cache.system_data_contracts; + + let system_data_contract_types = BTreeMap::from_iter([ + ( + SystemDataContract::DPNS, + system_data_contracts.load_dpns(platform_version)?, + ), + ( + SystemDataContract::Withdrawals, + system_data_contracts.load_withdrawals(platform_version)?, + ), + ( + SystemDataContract::Dashpay, + system_data_contracts.load_dashpay(platform_version)?, + ), + ( + SystemDataContract::MasternodeRewards, + system_data_contracts.load_masternode_reward_shares(platform_version)?, + ), + ( + SystemDataContract::TokenHistory, + system_data_contracts.load_token_history(platform_version)?, + ), + ( + SystemDataContract::KeywordSearch, + system_data_contracts.load_keyword_search(platform_version)?, + ), + ( + SystemDataContract::DocumentHistory, + system_data_contracts.load_document_history(platform_version)?, + ), + ( + SystemDataContract::AppConnect, + system_data_contracts.load_app_connect(platform_version)?, + ), + ]); + + for data_contract in system_data_contract_types.values() { + self.register_system_data_contract_operations( + data_contract, + &mut operations, + platform_version, + )?; + } + + let wallet_utils_contract = + load_system_data_contract(SystemDataContract::WalletUtils, platform_version)?; + + self.register_system_data_contract_operations( + &wallet_utils_contract, + &mut operations, + platform_version, + )?; + + let dpns_contract = system_data_contracts.load_dpns(platform_version)?; + + self.register_dpns_top_level_domain_operations( + &dpns_contract, + genesis_time, + &mut operations, + )?; + + let block_info = BlockInfo::default_with_time(genesis_time); + + self.drive.apply_drive_operations( + operations, + true, + &block_info, + transaction, + platform_version, + None, // No previous_fee_versions needed for genesis state creation + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + mod create_genesis_state { + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contracts::SystemDataContract; + use platform_version::version::PlatformVersion; + + /// The app-connect contract is part of the genesis state from protocol version 14 + /// on, where the tables select this generation, and absent from the genesis state of + /// every earlier version, which still runs v1 and which a replaying node must + /// reproduce byte for byte. Both sides go through the `create_genesis_state` + /// dispatcher. + #[test] + pub fn should_register_the_app_connect_contract_only_from_protocol_version_14() { + let app_connect_id = SystemDataContract::AppConnect.id(); + + for (initial_protocol_version, expected_method_version, expected) in + [(13, 1, false), (14, 2, true)] + { + let platform_version = PlatformVersion::get(initial_protocol_version) + .expect("expected a supported platform version"); + assert_eq!( + platform_version + .drive_abci + .methods + .initialization + .create_genesis_state, + expected_method_version, + "protocol version {initial_protocol_version} must dispatch to create_genesis_state v{expected_method_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( + app_connect_id.to_buffer(), + None, + None, + None, + platform_version, + ) + .value + .expect("expected to query the app-connect contract"); + + assert_eq!( + stored.is_some(), + expected, + "app-connect contract presence in a genesis state born at protocol version {initial_protocol_version}" + ); + + if let Some(stored) = stored { + assert_eq!(stored.contract.id(), app_connect_id); + assert!(stored + .contract + .document_type_for_name("loginKeyResponse") + .is_ok()); + assert!(stored + .contract + .document_type_for_name("appManifest") + .is_ok()); + } + } + } + } +} 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 b29235d76d2..8504cf73b26 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 @@ -734,7 +734,7 @@ impl Platform { // App-connect contract: the wallet's encrypted login key response and the app's // manifest get one system contract id on every network from this version. Fresh - // chains register it at genesis (`create_genesis_state` v1). + // chains register it at genesis (`create_genesis_state` v2). let app_connect_contract = load_system_data_contract(SystemDataContract::AppConnect, platform_version)?; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index bc98d8b3a97..0913b20823a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -30,7 +30,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet }, initialization: DriveAbciInitializationMethodVersions { initial_core_height_and_time: 0, - create_genesis_state: 1, + create_genesis_state: 2, // registers the app-connect contract at genesis }, core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { update_core_info: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 22ea238f65a..eeaa54bb505 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -377,6 +377,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// staying in the pot. `DRIVE_ABCI_VALIDATION_VERSIONS_V10` turns its gates /// on, `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` adds its converter, and /// the verify table gains `verify_contract_fee_pots`. +/// 18. **The app-connect system contract**: `SystemDataContract::AppConnect` +/// (`app_connect: 1` in `SYSTEM_DATA_CONTRACT_VERSIONS_V3`) is a new +/// persisted system contract carrying the wallet-to-app login handshake, +/// the wallet's encrypted `loginKeyResponse` and the app's `appManifest`. +/// Fresh chains register it at genesis (`create_genesis_state` 2, +/// `DRIVE_ABCI_METHOD_VERSIONS_V10`); chains upgrading from 13 receive it +/// from `transition_to_version_14`, which inserts it beside the DashPay v2 +/// and withdrawals v2 rewrites. Below 14 it does not exist in state and +/// the system contract cache reports it absent. /// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in @@ -437,7 +446,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot; contract method versions v4: the moderation list trees and the moderation method table drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each - methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit; create_genesis_state v2 registers the app-connect contract validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time @@ -459,7 +468,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, // changed: daily_withdrawal_limit v2 — a percentage of the total credits a day ago factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33); withdrawals v2 admits the terminal FAILED status + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33); withdrawals v2 admits the terminal FAILED status; the app-connect contract is new (registered at genesis by create_genesis_state v2, inserted on upgrade by transition_to_version_14) // The TTL ephemeral-bytes rate (270 credits/byte to processing) rides // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. From 7c1686a1cf52aa4aec96231591c99c5a19b02364 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 19 Sep 2026 14:56:39 -0500 Subject: [PATCH 3/6] feat(platform)!: owner agreement on contract references A refersTo of type contract may carry propertyAgreement { $ownerId: $ownerId }: the writer must be the referenced contract's owner, so only that owner may create or replace the referring document. Protocol 14: meta-schema v3 admits exactly that pair on contract references; apply_property_reference generation 1 parses it to a new appended DocumentPropertyReferenceTarget::ContractOwnerGated variant (the enum is append-only and consensus-serialized, so the plain Contract encoding is untouched); document reference validation 1 compares the writer to the contract's owner, re-checked on every replace, and rejects with ReferencedDocumentPropertyMismatchError; contract reference validation 1 admits the grammar (nothing to resolve at registration). Tests: full-pipeline owner-gate tests (non-owner refused, owner create and replace accepted, transferee replace refused, missing contract not found), registration fixture, parser, meta-validator, serde shape, wasm-dpp2 surface. Co-Authored-By: Claude Fable 5.1 --- book/src/drive/index-only-document-types.md | 8 +- packages/js-evo-sdk/README.md | 5 +- .../document/v3/document-meta.json | 35 +- .../try_from_schema/common/mod.rs | 1 + .../class_methods/try_from_schema/mod.rs | 188 ++++++ .../document_type/property/mod.rs | 43 ++ .../src/validation/meta_validators/mod.rs | 52 ++ .../document_reference_validation/mod.rs | 18 +- .../document_reference_validation/v0/mod.rs | 7 +- .../document_reference_validation/v1/mod.rs | 547 ++++++++++++++++ .../tests/document/contract_owner_gate.rs | 583 ++++++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../data_contract_reference_validation/mod.rs | 15 +- .../v1/mod.rs | 42 ++ .../data_contract_create/mod.rs | 16 + ...tion-contract-owner-gate-transferable.json | 40 ++ ...-validation-contract-owner-gate-valid.json | 50 ++ .../dpp_versions/dpp_contract_versions/v6.rs | 2 +- .../drive_abci_validation_versions/v10.rs | 11 +- .../rs-platform-version/src/version/v14.rs | 9 + .../data_contract/document_type_reference.rs | 22 +- .../unit/DocumentPropertyReference.spec.ts | 23 + 22 files changed, 1705 insertions(+), 13 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_gate.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v1/mod.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-transferable.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json diff --git a/book/src/drive/index-only-document-types.md b/book/src/drive/index-only-document-types.md index 4203300b60b..0288247399d 100644 --- a/book/src/drive/index-only-document-types.md +++ b/book/src/drive/index-only-document-types.md @@ -194,7 +194,13 @@ sentinel disappears (see the absence-aware `propertyAgreement` below). may have been transferred in between; a transfer itself is not re-checked, so on a transferable referring type it governs writing, not holding. A writer gate does not make an owner-prefixed index - preallocatable. + preallocatable. A `contract` reference admits the same writer gate and + nothing else: `refersTo: { "type": "contract", "propertyAgreement": + { "$ownerId": "$ownerId" } }` lets only the referenced contract's owner + create or replace the referring document (the app-connect `appManifest` + uses it so only an app's owner can publish the app's manifest). A + contract's owner never changes, so that gate is fixed for the + contract's lifetime. - **Delete** is its own transition kind, `DocumentIndexOnlyDeleteTransition { base, data }` (`$action: "indexOnlyDelete"`), carrying the full value tuple (`$createdAt` under diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 58e123c0912..3792dffd7f8 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -198,7 +198,10 @@ for (const ref of contract.documentTypeReferences('note')) { // or `$creatorId`, e.g. `propertyAgreement: { authorId: '$ownerId' }`, and // the referring side may be the writer's own `$ownerId`: a write gate such // as `{ '$ownerId': '$ownerId' }` lets only the referenced document's - // current owner create or replace the referring document. + // current owner create or replace the referring document. A `contract` + // reference admits that one gate too: + // { path: 'appContractId', type: 'contract', propertyAgreement: { '$ownerId': '$ownerId' } } + // means only the referenced contract's owner may write the document. console.log(ref.path, ref.type); } diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 86f837b7df8..16932322a0c 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -139,7 +139,7 @@ "pattern": "^[a-zA-Z0-9-_]{1,64}(\\.[a-zA-Z0-9-_]{1,64})*$" }, "propertyAgreement": { - "description": "permanentDocument references only: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId and $creatorId system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId never changes; either is checked when the referring document is written, not when the referenced document later moves", + "description": "On permanentDocument references: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId and $creatorId system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId never changes; either is checked when the referring document is written, not when the referenced document later moves. On contract references the only admitted pair is { \"$ownerId\": \"$ownerId\" }: the writer's $ownerId must equal the referenced contract's owner id, so only the contract's owner may create or replace the referring document; contract ownership never changes, so the gate is fixed for the contract's lifetime", "type": "object", "minProperties": 1, "maxProperties": 10, @@ -204,6 +204,39 @@ "keyIdProperty": false } } + }, + { + "$comment": "propertyAgreement is admitted on permanentDocument references (any pairs) and on contract references (only the owner gate); identity, token and identityPublicKey references have nothing to agree with", + "if": { + "properties": { "type": { "const": "contract" } }, + "required": ["type"] + }, + "then": { + "properties": { + "propertyAgreement": { + "type": "object", + "properties": { + "$ownerId": { + "const": "$ownerId" + } + }, + "required": ["$ownerId"], + "additionalProperties": false + } + } + }, + "else": { + "if": { + "properties": { "type": { "const": "permanentDocument" } }, + "required": ["type"] + }, + "then": true, + "else": { + "properties": { + "propertyAgreement": false + } + } + } } ] }, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 42e89cf3cfe..873b14c5c5e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -2414,6 +2414,7 @@ pub(super) fn apply_index_only( DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::ContractOwnerGated | DocumentPropertyReferenceTarget::Token | DocumentPropertyReferenceTarget::PermanentDocument { .. } ) 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 0665870fbb5..c2fdf8b5024 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 @@ -9,6 +9,7 @@ use crate::data_contract::document_type::{ }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; +use crate::document::property_names::OWNER_ID; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; @@ -342,12 +343,72 @@ fn apply_property_reference( { None => Ok(property_type), Some(0) => apply_property_reference_v0(inner_properties, property_type), + Some(1) => apply_property_reference_v1(inner_properties, property_type), Some(version) => Err(DataContractError::Unsupported(format!( "apply_property_reference version {version} is not supported" ))), } } +/// Generation 1 is generation 0 plus the owner gate on `contract` references: +/// `refersTo: { type: "contract", propertyAgreement: { "$ownerId": "$ownerId" } }` +/// parses to [`DocumentPropertyReferenceTarget::ContractOwnerGated`], and any +/// other agreement pair on a contract reference is refused. A `refersTo` +/// without an agreement, or on any other target, is delegated to generation 0 +/// unchanged; the identifier check runs first, as it does there. +fn apply_property_reference_v1( + inner_properties: &BTreeMap, + property_type: DocumentPropertyType, +) -> Result { + let Some(refers_to_value) = inner_properties.get(property_names::REFERS_TO) else { + return Ok(property_type); + }; + + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is only allowed on identifier properties".to_string(), + )); + } + + let refers_to_map = refers_to_value.to_btree_ref_string_map()?; + + let Some(agreement_value) = refers_to_map.get(property_names::PROPERTY_AGREEMENT) else { + return apply_property_reference_v0(inner_properties, property_type); + }; + + let is_contract_reference = refers_to_map + .get_str(property_names::TYPE) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))? + == "contract"; + + if !is_contract_reference { + return apply_property_reference_v0(inner_properties, property_type); + } + + // A contract has no document body to agree with: the one pair a contract + // reference admits binds the writer to the contract's owner. + let agreement_map = agreement_value.to_btree_ref_string_map()?; + match agreement_map.iter().collect::>().as_slice() { + [(referring_property, referenced_value)] + if referring_property.as_str() == OWNER_ID + && referenced_value.as_text() == Some(OWNER_ID) => + { + Ok(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::ContractOwnerGated, + )) + } + _ => Err(DataContractError::InvalidContractStructure( + "propertyAgreement on a contract reference admits exactly one pair, \ + { \"$ownerId\": \"$ownerId\" }: the writer must be the referenced \ + contract's owner" + .to_string(), + )), + } +} + fn apply_property_reference_v0( inner_properties: &BTreeMap, property_type: DocumentPropertyType, @@ -675,6 +736,133 @@ mod tests { ); } + #[test] + fn should_parse_owner_gate_on_contract_refers_to() { + let document_type = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "propertyAgreement": { "$ownerId": "$ownerId" } + } + } + }, + "required": [], + "additionalProperties": false + })) + .expect("should parse"); + + let property_type = document_type + .as_ref() + .flattened_properties() + .get("appContractId") + .map(|p| p.property_type.clone()) + .expect("property should be present"); + + assert_eq!( + property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::ContractOwnerGated + ) + ); + } + + #[test] + fn should_reject_any_other_agreement_pair_on_a_contract_reference() { + for agreement in [ + json!({ "$ownerId": "$creatorId" }), + json!({ "authorId": "$ownerId" }), + json!({ "$ownerId": "$ownerId", "name": "name" }), + json!({}), + ] { + let err = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "authorId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "name": { "type": "string", "position": 1, "maxLength": 63 }, + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 2, + "refersTo": { + "type": "contract", + "propertyAgreement": agreement + } + } + }, + "required": [], + "additionalProperties": false + })) + .expect_err("should fail"); + + let message = err.to_string(); + assert!( + message + .contains("propertyAgreement on a contract reference admits exactly one pair"), + "unexpected error for {agreement}: {message}" + ); + } + } + + /// Protocol version 13 predates the `refersTo` keyword (`apply_property_reference` + /// is `None` there), so a contract carrying the owner gate parses under it + /// exactly as it always did: the keyword is ignored and the property is a + /// plain identifier. The gate is protocol-14 grammar; pre-14 nodes never see + /// it in the parsed type and consensus never evaluates it there. + #[test] + fn should_ignore_owner_gate_on_contract_reference_before_protocol_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + let document_type = try_document_type_from_schema_on_version( + json!({ + "type": "object", + "properties": { + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "propertyAgreement": { "$ownerId": "$ownerId" } + } + } + }, + "required": [], + "additionalProperties": false + }), + platform_version, + ) + .expect("should parse under protocol version 13"); + + let property_type = document_type + .as_ref() + .flattened_properties() + .get("appContractId") + .map(|p| p.property_type.clone()) + .expect("property should be present"); + + assert_eq!(property_type, DocumentPropertyType::Identifier); + } + #[test] fn should_reject_property_agreement_on_non_document_reference() { let err = try_document_type_from_schema(json!({ 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 68f5377d5ba..882604ce0b0 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 @@ -148,6 +148,21 @@ pub enum DocumentPropertyReferenceTarget { /// referenced key id key_id_property: String, }, + /// A data contract whose owner must be the writer: the schema's + /// `refersTo: { type: "contract", propertyAgreement: { "$ownerId": "$ownerId" } }`. + /// The only property agreement a contract reference admits (a contract has + /// no document body to agree with): the writer's id must equal the + /// referenced contract's owner id, so only the contract's owner may create + /// or replace the referring document. A contract's owner never changes, + /// so unlike the permanent-document writer gate this one is fixed for the + /// contract's lifetime. Checked on every create and every replace, since + /// the writer is transition metadata that never appears among the changed + /// fields. Its own variant, appended, so that the plain `Contract` + /// encoding every earlier protocol-14 contract carries is untouched; the + /// JSON tag keeps the schema keyword's `type` ("contract") out of the + /// picture, the wasm surface adds the agreement back. + #[serde(rename = "contractOwnerGated")] + ContractOwnerGated, } /// The system properties of a referenced document that the referenced side @@ -210,6 +225,9 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { write!(f, "identity public key (key id property {key_id_property})") } + DocumentPropertyReferenceTarget::ContractOwnerGated => { + write!(f, "contract (owner gate)") + } } } } @@ -7383,6 +7401,23 @@ mod tests { ); } + /// The plain contract reference keeps its unit-variant shape (the bytes + /// and JSON every protocol-14 contract already carries), and the gated + /// one is its own appended variant rather than a field on it. + #[test] + fn should_serialize_both_contract_reference_targets_as_unit_variants() { + assert_eq!( + serde_json::to_value(DocumentPropertyReferenceTarget::Contract) + .expect("expected to serialize"), + serde_json::json!("contract") + ); + assert_eq!( + serde_json::to_value(DocumentPropertyReferenceTarget::ContractOwnerGated) + .expect("expected to serialize"), + serde_json::json!("contractOwnerGated") + ); + } + #[test] fn should_display_reference_targets() { let contract_id = Identifier::from([7u8; 32]); @@ -7395,6 +7430,10 @@ mod tests { DocumentPropertyReferenceTarget::Contract.to_string(), "contract" ); + assert_eq!( + DocumentPropertyReferenceTarget::ContractOwnerGated.to_string(), + "contract (owner gate)" + ); assert_eq!(DocumentPropertyReferenceTarget::Token.to_string(), "token"); assert_eq!( DocumentPropertyReferenceTarget::PermanentDocument { @@ -7439,6 +7478,7 @@ mod tests { DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: "signerKeyId".to_string(), }, + DocumentPropertyReferenceTarget::ContractOwnerGated, ]; for target in &targets { @@ -7449,6 +7489,9 @@ mod tests { DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", + // The schema keyword is still `contract`; the gate is the + // `propertyAgreement` beside it, which the JS surface reports. + DocumentPropertyReferenceTarget::ContractOwnerGated => "contract", }; // The tag is the `refersTo` schema keyword's own `type` value, diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index f02d0cc5add..8962096cca2 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -339,6 +339,58 @@ mod tests { } } + #[test] + fn should_accept_the_owner_gate_on_a_contract_refers_to() { + let schema = document_schema_with_refers_to(json!({ + "type": "contract", + "propertyAgreement": { "$ownerId": "$ownerId" } + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected the owner gate on a contract refersTo to be valid" + ); + } + + #[test] + fn should_reject_any_other_agreement_on_a_contract_refers_to() { + for agreement in [ + json!({ "$ownerId": "$creatorId" }), + json!({ "authorId": "$ownerId" }), + json!({ "$ownerId": "$ownerId", "name": "name" }), + json!({}), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "contract", + "propertyAgreement": agreement + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected {agreement} on a contract refersTo to be invalid" + ); + } + } + + #[test] + fn should_reject_property_agreement_on_identity_token_and_key_refers_to() { + for target in ["identity", "token", "identityPublicKey"] { + let mut refers_to = json!({ + "type": target, + "propertyAgreement": { "$ownerId": "$ownerId" } + }); + if target == "identityPublicKey" { + refers_to["keyIdProperty"] = json!("toKeyIndex"); + } + let schema = document_schema_with_refers_to(refers_to); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected propertyAgreement on a {target} refersTo to be invalid" + ); + } + } + #[test] fn should_accept_permanent_document_refers_to_in_v3_document_schema() { let schema = document_schema_with_refers_to(json!({ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index c1246420c60..c2b732a8a14 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -1,4 +1,5 @@ pub mod v0; +pub mod v1; use std::collections::{BTreeMap, BTreeSet}; @@ -14,6 +15,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::v0::DocumentReferenceValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::v1::DocumentReferenceValidationV1; use crate::platform_types::platform::PlatformStateRef; pub(crate) trait DocumentReferenceValidation { @@ -27,7 +29,9 @@ pub(crate) trait DocumentReferenceValidation { /// /// `owner_id` is the writer, the transition's owner: a `propertyAgreement` /// whose referring side is `$ownerId` compares it, since it lives on the - /// transition rather than in `document_data`. + /// transition rather than in `document_data`. Version 1 also compares it + /// against the referenced contract's owner on a `contract` reference that + /// carries the owner gate. #[allow(clippy::too_many_arguments)] fn validate_document_references( &self, @@ -71,9 +75,19 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { execution_context, platform_version, ), + 1 => self.validate_document_references_v1( + document_data, + owner_id, + changed_fields, + platform, + block_info, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentBaseTransitionAction::validate_document_references".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index f801aa44a73..b4f49eeda5c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -148,8 +148,12 @@ fn validate_document_type_references_v0( DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { is_changed_field(changed, key_id_property) } + // `ContractOwnerGated` is protocol-14 grammar that generation-0 + // parsing never produces, so this generation never sees it; + // the arm only keeps the match exhaustive. DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::ContractOwnerGated | DocumentPropertyReferenceTarget::Token => false, }; if !is_changed_field(changed, path) && !bound_property_changed { @@ -180,7 +184,8 @@ fn validate_document_type_references_v0( .fetch_identity_revision(referenced_id, true, transaction, platform_version)? .is_some() } - DocumentPropertyReferenceTarget::Contract => { + DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::ContractOwnerGated => { let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( referenced_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs new file mode 100644 index 00000000000..698f208e3ab --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -0,0 +1,547 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::InvalidDocumentTypeError; +use dpp::consensus::basic::invalid_identifier_error::InvalidIdentifierError; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::{ + is_referring_system_agreement_property, DocumentPropertyReferenceTarget, + DocumentPropertyType, DocumentTypeRef, +}; +use dpp::data_contract::DataContract; +use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; +use dpp::document::DocumentV0Getters; +use dpp::errors::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; +use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; +use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; +use dpp::errors::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; +use dpp::errors::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; +use dpp::identifier::Identifier; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::KeyID; +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use dpp::platform_value::Value; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use std::borrow::Cow; +use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, OptionalSingleIdentityPublicKeyOutcome, +}; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::{RetrieveIdentityInfo, ValidationOperation}; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::fetch_document_with_id; +use crate::platform_types::platform::PlatformStateRef; + +/// Versioned, stateful validation of document references using the v1 rules. +/// +/// v1 is v0 plus the owner gate on `contract` references: a reference declared +/// with `propertyAgreement: { "$ownerId": "$ownerId" }` requires the writer to +/// be the referenced contract's owner, which is compared against the contract +/// already fetched for the existence check, so the gate adds no reads. Like +/// the permanent-document writer gate it is re-checked on every replace. The +/// v0 generation stays byte-identical for protocol version 13 replay. +pub(crate) trait DocumentReferenceValidationV1 { + #[allow(clippy::too_many_arguments)] + fn validate_document_references_v1( + &self, + document_data: &BTreeMap, + owner_id: Identifier, + changed_fields: Option<&BTreeSet>, + platform: &PlatformStateRef, + block_info: &BlockInfo, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentReferenceValidationV1 for DocumentBaseTransitionAction { + fn validate_document_references_v1( + &self, + document_data: &BTreeMap, + owner_id: Identifier, + changed_fields: Option<&BTreeSet>, + platform: &PlatformStateRef, + block_info: &BlockInfo, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result { + let contract_fetch_info = self.data_contract_fetch_info(); + let contract = &contract_fetch_info.contract; + let document_type_name = self.document_type_name(); + + let Some(document_type) = contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTypeError::new(document_type_name.clone(), contract.id()).into(), + )); + }; + + validate_document_type_references_v1( + contract, + document_type, + document_data, + owner_id, + changed_fields, + platform, + block_info, + transaction, + execution_context, + platform_version, + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn validate_document_type_references_v1( + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document_data: &BTreeMap, + owner_id: Identifier, + changed_fields: Option<&BTreeSet>, + platform: &PlatformStateRef, + block_info: &BlockInfo, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + for (path, property) in document_type.flattened_properties() { + let DocumentPropertyType::IdentifierWithReference(reference_target) = + &property.property_type + else { + continue; + }; + + if let Some(changed) = changed_fields { + // Some targets bind a sibling property of the same document to + // the reference; replacing that sibling must re-validate the + // reference even when the reference property itself is untouched: + // - a propertyAgreement pair binds each referring property; + // - an identityPublicKey reference binds the key id property, + // since the referenced key is the (identity id, key id) pair + // and a freshly written key id must exist and not be disabled. + // A writer gate (an agreement keyed by `$ownerId`, on a permanent + // document or on a contract) is re-checked on EVERY replace: the + // writer is transition metadata that never appears among the + // changed fields, and a referenced document may have been + // transferred since the last write, so a replace of an unrelated + // field by a now-unauthorized owner must still fail. A contract's + // owner never changes, but the referring document may have been + // transferred away from that owner, so the same rule applies. + let bound_property_changed = match reference_target { + DocumentPropertyReferenceTarget::PermanentDocument { + property_agreement, .. + } => property_agreement.keys().any(|referring_property| { + is_referring_system_agreement_property(referring_property) + || is_changed_field(changed, referring_property) + }), + DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + is_changed_field(changed, key_id_property) + } + DocumentPropertyReferenceTarget::ContractOwnerGated => true, + DocumentPropertyReferenceTarget::Identity + | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Token => false, + }; + if !is_changed_field(changed, path) && !bound_property_changed { + continue; + } + } + + let referenced_id = match document_data.get_optional_identifier_at_path(path) { + Ok(Some(referenced_id)) => referenced_id, + // A reference property that is not set is not validated; whether it may be + // absent at all is enforced by the document type's required fields + Ok(None) => continue, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + + let exists = match reference_target { + DocumentPropertyReferenceTarget::Identity => { + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::only_revision(), + )); + + platform + .drive + .fetch_identity_revision(referenced_id, true, transaction, platform_version)? + .is_some() + } + DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::ContractOwnerGated => { + let owner_gate = matches!( + reference_target, + DocumentPropertyReferenceTarget::ContractOwnerGated + ); + let (fee, referenced_contract) = + platform.drive.get_contract_with_fetch_info_and_fee( + referenced_id, + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + + let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; + + // The cost is added even if the referenced contract does not exist or was cached + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + // The owner gate: the writer must be the referenced contract's + // owner. The contract is already in hand for the existence + // check, so the comparison adds no reads. A missing contract + // falls through to the not-found error below instead. On a + // replace the gate is re-checked whether or not the reference + // changed: a contract's owner never changes, but a gated + // document on a transferable type may have moved to an + // identity that is not the owner, which then may not replace + // it (the permanent-document writer gate behaves the same). + if let Some(referenced_contract) = &referenced_contract { + if owner_gate && referenced_contract.contract.owner_id() != owner_id { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyMismatchError::new( + path.to_string(), + OWNER_ID.to_string(), + OWNER_ID.to_string(), + ) + .into(), + )); + } + } + + referenced_contract.is_some() + } + DocumentPropertyReferenceTarget::Token => { + // Token contract info is written for every token when its contract is + // inserted and is never deleted, so it serves as the existence record + let (referenced_token_info, fee) = + platform.drive.fetch_token_contract_info_with_costs( + referenced_id, + block_info, + true, + transaction, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + referenced_token_info.is_some() + } + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + } => { + // An absent contract id targets the declaring contract itself; the + // declaring contract may also name its own id explicitly. Either + // way it is already loaded for this transition, so no fetch is + // billed for it + let effective_contract_id = referenced_contract_id.unwrap_or(contract.id()); + let referenced_contract_fetch_info; + let referenced_contract = if effective_contract_id == contract.id() { + contract + } else { + let (fee, fetch_info) = platform.drive.get_contract_with_fetch_info_and_fee( + effective_contract_id.to_buffer(), + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + + let fee = + fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; + + // The cost is added even if the referenced contract does not exist or was cached + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let Some(fetch_info) = fetch_info else { + // A missing contract and a missing document type resolve to the + // same failure: the declared document type could not be found + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotFoundError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + }; + + referenced_contract_fetch_info = fetch_info; + &referenced_contract_fetch_info.contract + }; + + let Some(referenced_document_type) = + referenced_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotFoundError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + }; + + // Only document types whose documents can never be deleted may be + // referenced: `canBeDeleted` is immutable on contract updates and + // document types can not be removed, so a reference validated here + // can never dangle + if referenced_document_type.documents_can_be_deleted() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeDeletableError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + } + + let referenced_document = fetch_document_with_id( + platform.drive, + referenced_contract, + referenced_document_type, + Identifier::from(referenced_id), + &block_info.epoch, + execution_context, + transaction, + platform_version, + )?; + + // Property agreement: the referenced document is already in + // hand for the existence check, so comparing the declared + // pairs adds no reads. Each side is normalized through its + // OWN document type's key encoding — one deterministic + // normal form per value kind, so an identifier stored as + // bytes and one carried as an identifier compare equal. + // + // Absence is part of the agreement, strictly: both sides + // absent agree, one side absent is a mismatch. Anything + // laxer breaks the properties agreements exist for — with + // referring-absent-always-ok, a document could opt out of + // echoing a value its referenced document carries (e.g. a + // like on a TAGGED post omitting the tag, silently + // deflating every per-tag aggregate), and the referenced + // side's absence is what lets a referring doctype whose + // agreement key triggers a skipIfAbsent index stay + // consistently absent for untagged targets. + if let Some(referenced_document) = &referenced_document { + for (referring_property, referenced_property) in property_agreement { + let mismatch = || { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyMismatchError::new( + path.to_string(), + referring_property.clone(), + referenced_property.clone(), + ) + .into(), + ) + }; + // A lookup ERROR (a non-map value where the dotted + // path expects an intermediate object) is a + // mismatch, never absence — folding it into `None` + // would let two malformed sides "agree" as + // both-absent. + // The referring side is a schema property of the document + // being written, or the writer's own `$ownerId`, which + // lives on the transition rather than in its data: that + // pair is a write gate, and the writer is `owner_id`. + let referring_value: Option> = if referring_property == OWNER_ID + { + Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))) + } else { + let Ok(referring_value) = + document_data.get_optional_at_path(referring_property) + else { + return Ok(mismatch()); + }; + referring_value.map(Cow::Borrowed) + }; + // The referenced side may name one of the two system + // identifiers a document carries outside its data: + // `$ownerId`, which follows the document through + // transfers, and `$creatorId`, set once at creation + // and absent on document types that do not record + // it. Contract registration validated that either + // faces an identifier property on the referring + // side, and the key serializer below already encodes + // both names as 32-byte identifiers. + let referenced_value: Option> = + match referenced_property.as_str() { + OWNER_ID => Some(Cow::Owned(Value::Identifier( + referenced_document.owner_id().to_buffer(), + ))), + CREATOR_ID => referenced_document.creator_id().map(|creator_id| { + Cow::Owned(Value::Identifier(creator_id.to_buffer())) + }), + _ => { + let Ok(referenced_value) = referenced_document + .properties() + .get_optional_at_path(referenced_property) + else { + return Ok(mismatch()); + }; + referenced_value.map(Cow::Borrowed) + } + }; + let (referring_value, referenced_value) = + match (referring_value, referenced_value) { + (Some(referring_value), Some(referenced_value)) => { + (referring_value, referenced_value) + } + // Both absent: the sides agree. + (None, None) => continue, + // One side absent: a mismatch, exactly as a + // differing value would be. + (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), + }; + let Ok(referring_encoded) = document_type.serialize_value_for_key( + referring_property, + &referring_value, + platform_version, + ) else { + return Ok(mismatch()); + }; + let Ok(referenced_encoded) = referenced_document_type + .serialize_value_for_key( + referenced_property, + &referenced_value, + platform_version, + ) + else { + return Ok(mismatch()); + }; + if referring_encoded != referenced_encoded { + return Ok(mismatch()); + } + } + } + + referenced_document.is_some() + } + DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + // The referenced key id is carried by the named sibling property + let key_id: KeyID = + match document_data.get_optional_integer_at_path(key_id_property) { + Ok(Some(key_id)) => key_id, + Ok(None) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + "the key id property is not set".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + err.to_string(), + ) + .into(), + )) + } + }; + + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::one_key(), + )); + + // A missing identity and a missing key resolve to the same + // failure: the referenced key could not be found + let Some(key) = platform + .drive + .fetch_identity_keys::( + IdentityKeysRequest::new_specific_key_query(&referenced_id, key_id), + transaction, + platform_version, + )? + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyNotFoundError::new( + Identifier::from(referenced_id), + key_id, + path.to_string(), + ) + .into(), + )); + }; + + // Keys can never be removed, so an existing reference can not + // dangle; a disabled key is still rejected for fresh writes + if key.is_disabled() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyDisabledError::new( + Identifier::from(referenced_id), + key_id, + path.to_string(), + ) + .into(), + )); + } + + true + } + }; + + if !exists { + let missing_id = + Identifier::from_bytes(&referenced_id).map_err(|e| Error::Protocol(e.into()))?; + + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( + ReferencedEntityNotFoundError::new( + missing_id, + reference_target.clone(), + path.to_string(), + ), + )), + )); + } + } + + Ok(SimpleConsensusValidationResult::new()) +} + +/// A flattened property path counts as changed when the replace transition changed +/// the path itself or any of its ancestors: `changed_data_fields` holds top-level +/// document keys, so a changed object key replaces its entire subtree, including +/// any nested reference properties under it. +fn is_changed_field(changed_fields: &BTreeSet, path: &str) -> bool { + changed_fields.iter().any(|field| { + path == field + || path + .strip_prefix(field.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_gate.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_gate.rs new file mode 100644 index 00000000000..8b68615179b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_gate.rs @@ -0,0 +1,583 @@ +//! The owner gate on `contract` references through the full ABCI pipeline: +//! `refersTo: { type: "contract", propertyAgreement: { "$ownerId": "$ownerId" } }` +//! lets only the referenced contract's owner create or replace the referring +//! document. Exercised on the app-connect system contract's `appManifest`, +//! the first document type to carry it, and on a fixture contract. + +use super::*; + +mod contract_owner_gate_tests { + use super::*; + use crate::platform_types::platform_state::PlatformState; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contracts::SystemDataContract; + use dpp::document::Document; + use dpp::identifier::Identifier; + use dpp::identity::signer::Signer; + use dpp::identity::IdentityPublicKey; + use dpp::prelude::DataContract; + use dpp::state_transition::StateTransition; + use dpp::system_data_contracts::load_system_data_contract; + + /// `manifest.appContractId` carries the owner gate. + const OWNER_GATE_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json"; + + /// The contract whose owner the gate names: any registered contract + /// will do, so the basic token fixture stands in for an "app contract". + const APP_CONTRACT_PATH: &str = "tests/supporting_files/contract/basic-token/basic-token.json"; + + /// Same declaration on a transferable document type: the one shape on + /// which the gate's replace-time re-check can refuse anyone. + const OWNER_GATE_TRANSFERABLE_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-transferable.json"; + + fn register_contract( + platform: &TempPlatform, + path: &str, + id: Identifier, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> DataContract { + let mut contract = json_document_to_contract(path, true, platform_version) + .expect("expected to parse the contract"); + contract.set_id(id); + contract.set_owner_id(owner_id); + contract.set_config( + DataContractConfig::default_for_version(platform_version) + .expect("expected the default contract config"), + ); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + contract + } + + fn process_and_commit( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + platform_version: &PlatformVersion, + ) -> StateTransitionsProcessingResult { + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + processing_result + } + + /// Creates a document of `document_type_name` owned by `owner` whose + /// `reference_property` names `referenced_contract_id`, with `name` + /// set; returns the processing result and the document as submitted. + #[allow(clippy::too_many_arguments)] + async fn submit_gated_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + document_type_name: &str, + reference_property: &str, + referenced_contract_id: Identifier, + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> (StateTransitionsProcessingResult, Document) { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("document type exists"); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document.set( + reference_property, + Value::Identifier(referenced_contract_id.to_buffer()), + ); + document.set("name", "Yappr".into()); + // The random fill does not respect `maximum`; the manifest's bounds + // kind must be 0..=3 or JSON-schema validation refuses the document + // before the reference is ever checked. + if document_type + .flattened_properties() + .contains_key("authBoundsKind") + { + document.set("authBoundsKind", Value::U8(0)); + } + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + ( + process_and_commit(platform, platform_state, &create, platform_version), + document, + ) + } + + /// Replaces only `name` on `document`, signed by `key`'s identity. + #[allow(clippy::too_many_arguments)] + async fn replace_gated_document_name>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + document_type_name: &str, + document: &mut Document, + name: &str, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, + ) -> StateTransitionsProcessingResult { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("document type exists"); + document.set("name", name.into()); + document + .increment_revision() + .expect("expected the revision to increment"); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + process_and_commit(platform, platform_state, &replace, platform_version) + } + + /// Transfers `document` from its current owner (who signs) to `recipient`. + #[allow(clippy::too_many_arguments)] + async fn transfer_gated_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + document_type_name: &str, + document: &mut Document, + recipient: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, + ) { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("document type exists"); + document + .increment_revision() + .expect("expected the revision to increment"); + let transfer = BatchTransition::new_document_transfer_transition_from_document( + document.clone(), + document_type, + recipient, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition"); + let result = process_and_commit(platform, platform_state, &transfer, platform_version); + assert_eq!( + result.valid_count(), + 1, + "the document must be transferred: {:?}", + result.execution_results() + ); + document.set_owner_id(recipient); + } + + fn assert_owner_gate_refused(result: &StateTransitionsProcessingResult, because: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(_) + ), + .. + }], + "{because}" + ); + } + + /// The app-connect `appManifest`: its `appContractId` names the app's + /// contract and carries the owner gate, so the contract's owner may + /// publish the manifest and nobody else may. + #[tokio::test] + async fn test_app_manifest_is_only_writable_by_the_app_contract_owner() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4831); + + let (app_owner, app_owner_signer, app_owner_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let (stranger, stranger_signer, stranger_key) = + setup_identity(&mut platform, 450, dash_to_credits!(1.0)); + + // The "app": a user contract owned by `app_owner`. + let app_contract = register_contract( + &platform, + APP_CONTRACT_PATH, + Identifier::from([7u8; 32]), + app_owner.id(), + platform_version, + ); + + // The system contract is registered at genesis; load the same + // materialization to build transitions against. + let app_connect = + load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .expect("expected the app-connect contract"); + + // A stranger publishing a manifest for a contract they do not own is refused. + let (result, _) = submit_gated_document( + &platform, + &platform_state, + &app_connect, + "appManifest", + "appContractId", + app_contract.id(), + stranger.id(), + &stranger_key, + 2, + &stranger_signer, + &mut rng, + platform_version, + ) + .await; + assert_owner_gate_refused( + &result, + "an identity that does not own the app contract must not publish its manifest", + ); + + // The app contract's owner may. + let (result, mut manifest) = submit_gated_document( + &platform, + &platform_state, + &app_connect, + "appManifest", + "appContractId", + app_contract.id(), + app_owner.id(), + &app_owner_key, + 2, + &app_owner_signer, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + result.valid_count(), + 1, + "the app contract's owner must be able to publish the manifest: {:?}", + result.execution_results() + ); + + // And may replace it later; the gate is re-checked on the replace. + let result = replace_gated_document_name( + &platform, + &platform_state, + &app_connect, + "appManifest", + &mut manifest, + "Yappr, renamed", + &app_owner_key, + 3, + &app_owner_signer, + platform_version, + ) + .await; + assert_eq!( + result.valid_count(), + 1, + "the owner must be able to replace the manifest: {:?}", + result.execution_results() + ); + } + + /// A manifest naming a contract that does not exist fails the existence + /// check, not the gate: there is no owner to compare against. + #[tokio::test] + async fn test_app_manifest_for_a_missing_contract_is_not_found() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4832); + + let (owner, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let app_connect = + load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .expect("expected the app-connect contract"); + + let (result, _) = submit_gated_document( + &platform, + &platform_state, + &app_connect, + "appManifest", + "appContractId", + Identifier::from([9u8; 32]), + owner.id(), + &key, + 2, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)), + .. + }], + "a manifest for a contract that does not exist must be refused as not found" + ); + } + + /// The gate on a user contract's document type, through registration + /// and then writes: the fixture's `manifest.appContractId` carries it. + #[tokio::test] + async fn test_contract_owner_gate_on_a_user_contract() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4833); + + let (alice, alice_signer, alice_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let (bob, bob_signer, bob_key) = setup_identity(&mut platform, 450, dash_to_credits!(1.0)); + + // Alice owns the referenced contract, Bob owns the declaring one: + // the gate is about the referenced contract's owner, not the + // declaring contract's. + let referenced = register_contract( + &platform, + APP_CONTRACT_PATH, + Identifier::from([7u8; 32]), + alice.id(), + platform_version, + ); + let declaring = register_contract( + &platform, + OWNER_GATE_CONTRACT_PATH, + Identifier::from([8u8; 32]), + bob.id(), + platform_version, + ); + + let (result, _) = submit_gated_document( + &platform, + &platform_state, + &declaring, + "manifest", + "appContractId", + referenced.id(), + bob.id(), + &bob_key, + 2, + &bob_signer, + &mut rng, + platform_version, + ) + .await; + assert_owner_gate_refused( + &result, + "owning the declaring contract does not pass the gate on the referenced one", + ); + + let (result, mut document) = submit_gated_document( + &platform, + &platform_state, + &declaring, + "manifest", + "appContractId", + referenced.id(), + alice.id(), + &alice_key, + 2, + &alice_signer, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + result.valid_count(), + 1, + "the referenced contract's owner must pass the gate: {:?}", + result.execution_results() + ); + + // A replace by the owner passes; the gate is checked on every replace. + let result = replace_gated_document_name( + &platform, + &platform_state, + &declaring, + "manifest", + &mut document, + "renamed", + &alice_key, + 3, + &alice_signer, + platform_version, + ) + .await; + assert_eq!( + result.valid_count(), + 1, + "the owner must be able to replace: {:?}", + result.execution_results() + ); + } + + /// The gate is re-checked on every replace, not only when the reference + /// changes. A contract's owner never changes, so on a non-transferable + /// type that re-check can never refuse anyone; on a transferable type it + /// can: once the owner transfers a gated document to someone else, that + /// holder is not the contract's owner and may not replace it, even an + /// unrelated field. The permanent-document writer gate behaves the same. + #[tokio::test] + async fn test_contract_owner_gate_refuses_a_replace_by_a_transferee() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4834); + + let (alice, alice_signer, alice_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let (bob, bob_signer, bob_key) = setup_identity(&mut platform, 450, dash_to_credits!(1.0)); + + let referenced = register_contract( + &platform, + APP_CONTRACT_PATH, + Identifier::from([7u8; 32]), + alice.id(), + platform_version, + ); + let declaring = register_contract( + &platform, + OWNER_GATE_TRANSFERABLE_CONTRACT_PATH, + Identifier::from([8u8; 32]), + alice.id(), + platform_version, + ); + + let (result, mut document) = submit_gated_document( + &platform, + &platform_state, + &declaring, + "manifest", + "appContractId", + referenced.id(), + alice.id(), + &alice_key, + 2, + &alice_signer, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + result.valid_count(), + 1, + "the contract owner must pass the gate on create: {:?}", + result.execution_results() + ); + + transfer_gated_document( + &platform, + &platform_state, + &declaring, + "manifest", + &mut document, + bob.id(), + &alice_key, + 3, + &alice_signer, + platform_version, + ) + .await; + + // Bob holds the document but does not own the referenced contract: + // a replace of an unrelated field is refused by the re-checked gate. + let result = replace_gated_document_name( + &platform, + &platform_state, + &declaring, + "manifest", + &mut document, + "renamed by the transferee", + &bob_key, + 2, + &bob_signer, + platform_version, + ) + .await; + assert_owner_gate_refused( + &result, + "a transferee that does not own the referenced contract must not replace the document", + ); + } +} 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 cced05dfcb4..ea34ae76044 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 @@ -1,4 +1,5 @@ mod action_fees; +mod contract_owner_gate; mod creation; mod deletion; mod dpns; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs index f129339b307..0da6096cbe7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; @@ -18,7 +19,9 @@ use crate::execution::types::state_transition_execution_context::StateTransition /// referenced contract (the declaring contract itself when no contract id is /// named) must contain the referenced document type, and that type must forbid /// deletion. Identity, contract and token targets declare nothing beyond their -/// kind, so they have nothing to validate here. +/// kind, so they have nothing to validate here; the owner gate a contract +/// target may carry (version 1 grammar) is fully checked by the parser and +/// enforced per document at write time. pub(in crate::execution::validation::state_transition) fn validate_data_contract_references( contract: &DataContract, drive: &Drive, @@ -41,9 +44,17 @@ pub(in crate::execution::validation::state_transition) fn validate_data_contract transaction, platform_version, ), + 1 => v1::validate_data_contract_references_v1( + contract, + drive, + block_info, + execution_context, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "validate_data_contract_references".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v1/mod.rs new file mode 100644 index 00000000000..77dc2ca816e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v1/mod.rs @@ -0,0 +1,42 @@ +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::DataContract; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::query::TransactionArg; + +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::state_transitions::data_contract_common::data_contract_reference_validation::v0::validate_data_contract_references_v0; + +/// Version 1 is version 0 with the `contract` owner gate admitted. +/// +/// A `contract` reference declared with `propertyAgreement: { "$ownerId": +/// "$ownerId" }` carries no declaration content for registration to resolve: +/// a contract has no properties to agree with, and the referenced contract id +/// is a document value, not part of the declaration. The parser already +/// admits exactly that one pair and refuses every other, so at registration +/// the gate needs no state check. Its enforcement lives in document reference +/// validation v1, at write time, where the referenced contract is fetched for +/// the existence check anyway. +/// +/// This generation exists because a contract whose parsed references carry +/// the gate is protocol-14 grammar: it is selected only by protocol 14's +/// tables, and delegating to v0 keeps the shipped generation byte-identical. +pub(super) fn validate_data_contract_references_v1( + contract: &DataContract, + drive: &Drive, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + validate_data_contract_references_v0( + contract, + drive, + block_info, + execution_context, + transaction, + platform_version, + ) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 93c6246d5b5..00065899e52 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5812,6 +5812,22 @@ mod tests { ); } + /// A `contract` reference may carry the owner gate; registration has + /// nothing to resolve for it (the parser admits exactly that one pair), + /// so the contract is accepted and the gate is enforced at write time. + #[tokio::test] + async fn should_register_contract_with_owner_gate_on_a_contract_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// The writer is an identifier, so the referenced side must be one too. #[tokio::test] async fn should_reject_writer_owner_agreement_against_a_non_identifier_property() { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-transferable.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-transferable.json new file mode 100644 index 00000000000..9e26f45ded1 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-transferable.json @@ -0,0 +1,40 @@ +{ + "$formatVersion": "1", + "id": "C9cmcgqgaD1P1wGjyHpc1SLsHCiD8VHJCKyGciPrGyDe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "manifest": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "transferable": 1, + "properties": { + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "propertyAgreement": { + "$ownerId": "$ownerId" + } + } + }, + "name": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "appContractId", + "name" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json new file mode 100644 index 00000000000..4fe55a598fc --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-gate-valid.json @@ -0,0 +1,50 @@ +{ + "$formatVersion": "1", + "id": "C9cmcgqgaD1P1wGjyHpc1SLsHCiD8VHJCKyGciPrGyDe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "manifest": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "indices": [ + { + "name": "byApp", + "properties": [ + { + "appContractId": "asc" + } + ], + "unique": true + } + ], + "properties": { + "appContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "propertyAgreement": { + "$ownerId": "$ownerId" + } + } + }, + "name": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "appContractId", + "name" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index a5ec478500c..4e8a1e3145f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -80,7 +80,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { should_add_creator_id: 1, enrich_with_base_schema: 1, find_identifier_and_binary_paths: 0, - apply_property_reference: Some(0), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type; None before this version means the keyword is ignored, as it was before it existed + apply_property_reference: Some(1), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type (generation 1 also parses the owner gate on contract references); None before this version means the keyword is ignored, as it was before it existed apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 87db84be22e..c5d870bb357 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -17,6 +17,13 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // the `document_reference_validation` feature version. Also bump // `delete_withdrawal_data_trigger` to 2 so owners can delete withdrawals in the // terminal FAILED status the withdrawals contract v2 admits. +// `document_reference_validation` and `data_contract_reference_validation` +// move to 1 in place (protocol version 14 introduced both slots): a `contract` +// reference may carry the owner gate `propertyAgreement: { "$ownerId": +// "$ownerId" }`, so only the referenced contract's owner may write the +// referring document. Registration has nothing to resolve for the gate (the +// parser admits exactly that one pair); its generation 1 marks the grammar +// and delegates to 0. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -139,7 +146,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, - data_contract_reference_validation: 0, + data_contract_reference_validation: 1, // changed: admits the owner gate on contract references (nothing to resolve at registration; delegates to 0) batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, advanced_structure: 1, @@ -236,7 +243,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = document_transfer_transition_state_validation: 0, document_purchase_transition_state_validation: 0, document_update_price_transition_state_validation: 0, - document_reference_validation: 0, + document_reference_validation: 1, // changed: the owner gate on contract references token_mint_transition_structure_validation: 0, token_burn_transition_structure_validation: 0, token_transfer_transition_structure_validation: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index eeaa54bb505..f1d4466eacd 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -164,6 +164,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `refersTo` reference keyword and the `timeRange` index transform. v13 /// keeps validating against meta-schema v2, where those keys are rejected /// as unknown properties, so a pre-v14 contract cannot smuggle them in. +/// A `contract` reference may carry the owner gate +/// (`propertyAgreement: { "$ownerId": "$ownerId" }`): `apply_property_reference` +/// generation 1 parses it (as its own appended `ContractOwnerGated` +/// target), contract reference validation 1 admits it (registration has +/// nothing to resolve), and document reference validation 1 refuses a +/// create or replace by anyone but the referenced contract's owner +/// (`ReferencedDocumentPropertyMismatchError`, 40127). The +/// app-connect `appManifest` is the first user: only an app contract's +/// owner can publish its manifest. /// It also bumps `validate_schema_compatibility` to 1, which strips the /// top-level `indices` key before diffing the old and new document type /// schemas: index immutability is enforced by `validate_update` v1's diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index a76d8ff1d9e..ca41fc64324 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -32,7 +32,17 @@ const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" */ export type DocumentPropertyReferenceTarget = | { type: 'identity' } - | { type: 'contract' } + | { + type: 'contract'; + /** + * Present as `{ '$ownerId': '$ownerId' }` when the reference carries + * the owner gate: only the referenced contract's owner may create or + * replace the referring document (consensus refuses anyone else with + * code 40127). The only agreement a contract reference admits. Absent + * when the declaration carries none. + */ + propertyAgreement?: { '$ownerId': '$ownerId' }; + } | { type: 'token' } | { type: 'permanentDocument'; @@ -131,7 +141,8 @@ fn reference_to_js( let kind = match target { DocumentPropertyReferenceTarget::Identity => "identity", - DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::ContractOwnerGated => "contract", DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", @@ -142,6 +153,13 @@ fn reference_to_js( DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::ContractOwnerGated => { + // The owner gate is reported in the schema keyword's own shape; + // a plain contract reference carries no `propertyAgreement` key. + let agreement = Object::new(); + set_field(&agreement, "$ownerId", &JsValue::from_str("$ownerId"), path)?; + set_field(&object, "propertyAgreement", &agreement, path)?; + } DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name, diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index e63c0ef1018..43ebaba6188 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -78,6 +78,13 @@ const schemas = { }, additionalProperties: false, }, + // A `contract` reference may carry the owner gate: only the referenced + // contract's owner may write the document. The only agreement a + // contract reference admits. + ownedContract: identifierProperty(8, { + type: 'contract', + propertyAgreement: { $ownerId: '$ownerId' }, + }), }, additionalProperties: false, }, @@ -124,6 +131,7 @@ describe('DataContract — refersTo declarations (v14)', () => { 'otherDoc', 'signerKey', 'meta.ownerRef', + 'ownedContract', ]); }); @@ -139,6 +147,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(byPath.get('otherDoc')!.type).to.equal('permanentDocument'); expect(byPath.get('signerKey')!.type).to.equal('identityPublicKey'); expect(byPath.get('meta.ownerRef')!.type).to.equal('identity'); + expect(byPath.get('ownedContract')!.type).to.equal('contract'); }); it('should carry no target fields for the bare kinds', () => { @@ -194,6 +203,20 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(other).to.not.have.property('propertyAgreement'); }); + it("should report the owner gate on a contract reference in the schema keyword's shape", () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const owned = references.find((reference) => reference.path === 'ownedContract')!; + const plain = references.find((reference) => reference.path === 'sourceContract')!; + + expect(owned).to.deep.equal({ + path: 'ownedContract', + type: 'contract', + propertyAgreement: { $ownerId: '$ownerId' }, + }); + expect(plain).to.deep.equal({ path: 'sourceContract', type: 'contract' }); + }); + it('should carry keyIdProperty for an identityPublicKey reference', () => { const contract = buildContract(14); const references = contract.documentTypeReferences('note') as Reference[]; From 728ac656eca5a1c68c99725ae96e5c8a2a5b7625 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 19 Sep 2026 14:56:50 -0500 Subject: [PATCH 4/6] test(drive-abci): re-pin the protocol 14 fee baselines for the app-connect genesis contract The app-connect system contract registered at a v14 genesis is one more sibling under the contracts subtree, so every byte-billed contract-tree read grows: delete 1721520 -> 1770160, replace 1450220 -> 1498860, DPNS domain create 6021500 -> 6175580. Same cause as the DashPay v2 bump in #4768. Verified against pristine v4.2-dev, where all three pins still pass. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/batch/tests/document/deletion.rs | 6 ++++-- .../state_transitions/batch/tests/document/dpns.rs | 6 ++++-- .../state_transitions/batch/tests/document/replacement.rs | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) 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 ae3871935b8..ea8c6360881 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 @@ -14,8 +14,10 @@ mod deletion_tests { // (one stored byte, five estimated), shifting processing costs // Protocol version 14 adds +740 per document write (the contract's version // item is one more node to rehash) and the larger DashPay v2 schema - // increases byte-billed contract-tree reads. - 1721520, + // increases byte-billed contract-tree reads. The app-connect system + // contract registered at a v14 genesis is one more sibling under the + // contracts subtree, so every contract-tree read bills more bytes. + 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 1503cbdf664..5affd7ec838 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 @@ -27,8 +27,10 @@ mod dpns_tests { // reading the old value, billing one fewer seek than the V3 path. // +740 per document write: the contract's version item is one more // node to rehash. +4_300 per domain create: the v2 state validation - // probes contested storage for the id. - 6_021_500, + // probes contested storage for the id. The app-connect system + // contract registered at a v14 genesis is one more sibling under the + // contracts subtree, so every contract-tree read bills more bytes. + 6_175_580, ) .await; } 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 664e4b00ed3..dd58e7d0d74 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 @@ -505,8 +505,10 @@ mod replacement_tests { // the old value, billing slightly fewer reads than the V3 path // Protocol version 14 adds +740 per document write (the contract's version // item is one more node to rehash) and the larger DashPay v2 schema - // increases byte-billed contract-tree reads. - 1450220, + // increases byte-billed contract-tree reads. The app-connect system + // contract registered at a v14 genesis is one more sibling under the + // contracts subtree, so every contract-tree read bills more bytes. + 1498860, ) .await; } From 0040bbbf0c191f5cd92576b5b24b0676ec294ca8 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 19 Sep 2026 14:57:02 -0500 Subject: [PATCH 5/6] fix(app-connect)!: gate the manifest on the app contract owner; rename encBindings appManifest.appContractId carries the owner gate (refersTo contract with propertyAgreement { $ownerId: $ownerId }), so consensus refuses a manifest from anyone but the app contract's owner and the unique index becomes byApp on appContractId alone; wallets fetch the manifest by app contract id and need no owner check. encBindings is renamed requestedEncryptionKeys (same packed 96-byte encoding) and documented purpose-first: the wallet registers the listed keys on the identity at login. Session limits are the app's request, a default the wallet may raise or lower. The login model is stated as such: whoever scans the request logs in, as with any QR or passkey login. Co-Authored-By: Claude Fable 5.1 --- docs/protocol/app-connect.md | 81 +++++++++++-------- packages/app-connect-contract/README.md | 13 +-- .../v1/app-connect-contract-documents.json | 24 +++--- packages/app-connect-contract/src/v1/mod.rs | 17 ++-- .../test/unit/appConnectContract.spec.js | 14 ++-- 5 files changed, 84 insertions(+), 65 deletions(-) diff --git a/docs/protocol/app-connect.md b/docs/protocol/app-connect.md index 6a5a3eb973a..240539eb594 100644 --- a/docs/protocol/app-connect.md +++ b/docs/protocol/app-connect.md @@ -56,11 +56,15 @@ rather than assume there is exactly one. ### `appManifest` Published once by the owner of an app's data contract and updated when the app's requirements -change. A wallet only trusts a manifest whose `$ownerId` is the owner of the contract it names. +change. Consensus refuses a manifest from anyone but the contract's owner: `appContractId` +carries the owner gate, `refersTo: { type: contract, propertyAgreement: { "$ownerId": +"$ownerId" } }`, so a create or replace whose writer is not the referenced contract's owner is +rejected (`ReferencedDocumentPropertyMismatchError`). A wallet therefore fetches the manifest by +`appContractId` and needs no owner check of its own. | Property | Type | Meaning | |---|---|---| -| `appContractId` | identifier, `refersTo: contract` | The app's data contract. | +| `appContractId` | identifier, `refersTo: contract` with the owner gate | The app's data contract. Only its owner can write this document. | | `name` | string, at most 64 characters | Display name, shown on the wallet's approval sheet. | | `url` | string, at most 256 characters | The app's URL, shown on the approval sheet. | | `authBoundsKind` | integer 0 to 3 | The contract bounds the login key must carry: `0` none, `1` the contract in `authBoundsId`, `2` the document type `authBoundsDocType` of that contract, `3` the contract group in `authBoundsId`. | @@ -68,39 +72,53 @@ change. A wallet only trusts a manifest whose `$ownerId` is the owner of the con | `authBoundsDocType` | string, at most 64 characters, optional | The document type name, present only when `authBoundsKind` is `2`. | | `sessionSeconds` | integer | The login key lifetime the app asks for, in seconds. | | `sessionBudget` | integer | The login key budget the app asks for, in credits. | -| `encBindings` | 0 to 768 bytes, optional | The encryption key bindings the app needs, packed as fixed 96-byte records (below). | +| `requestedEncryptionKeys` | 0 to 768 bytes, optional | The encryption keys the wallet should register on the identity at login, packed as fixed 96-byte records (below). | `appContractId`, `name`, `url`, `authBoundsKind`, `sessionSeconds` and `sessionBudget` are -required. The single index, `byOwnerAndApp` on `($ownerId, appContractId)`, is unique. Wallets -query by both values, so an identity can publish at most one manifest per app contract and cannot -publish one that a wallet would accept for a contract it does not own. +required. The single index, `byApp` on `(appContractId)`, is unique: one manifest per app +contract, and with the owner gate, one that only the contract's owner could have written. + +A contract's owner never changes, so the gate is fixed for the contract's lifetime and an +existing manifest always belongs to the current owner. Wallets should still treat a manifest +whose `$ownerId` differs from the contract's owner as absent: that cannot happen through +consensus today, but it is the invariant the wallet relies on, and checking it costs nothing +once both documents are in hand. The bounds are a requirement: the wallet registers the login key with exactly those `contractBounds` (see [contract-bound authentication keys](contract-bound-authentication-keys.md)), -or refuses. The lifetime and budget are requests: they cap what the wallet grants, and the wallet -may grant less (see [authentication keys with a budget or an expiry](authentication-key-limits.md)). +or refuses. The lifetime and budget are the app's request; the wallet treats them as a default +and may grant less or more (see +[authentication keys with a budget or an expiry](authentication-key-limits.md)). `authBoundsKind = 0` is legal; the expiry and budget still apply, only the scope does not. -#### The `encBindings` record +#### `requestedEncryptionKeys` + +The app cannot register keys on the user's identity; the wallet does that at login. Encryption +keys are per data contract (a Yappr user holds one pair for its DM contract, one for its social +contract, and so on), so the app lists the contracts and document types it will encrypt under +and which purposes it needs on each. At login the wallet walks this list, checks which of those +keys the identity already holds, and adds the missing ones in the same identity update as the +login key. -Document schemas admit byte arrays but not arrays of objects, so the bindings are packed. Each -record is 96 bytes: +Document schemas admit byte arrays but not arrays of objects, so the list is packed. Each record +is 96 bytes: | Offset | Size | Content | |---|---|---| | 0 | 32 | The id of the data contract the keys serve. | | 32 | 1 | Purpose mask: bit 0 asks for an ENCRYPTION key, bit 1 for a DECRYPTION key. | -| 33 | 63 | The document type name, zero-padded; all zero for a contract-level binding. | +| 33 | 63 | The document type name, zero-padded; all zero for a contract-level key. | The array holds zero to eight records, so its length is a multiple of 96 up to 768. For each record and each purpose bit, the wallet makes sure the identity holds an enabled key with that purpose, bound to that contract (or that document type of it), and registers one if it does not. The bound contract or document type has to opt in with `requiresIdentityEncryptionBoundedKey` or -`requiresIdentityDecryptionBoundedKey` for the binding to be registrable. +`requiresIdentityDecryptionBoundedKey` for the key to be registrable. -Platform does not parse `encBindings`; it is a byte array to consensus, and the layout above is -a convention between apps and wallets. The Rust crate exposes the offsets and the purpose bits as -constants under `app_connect_contract::v1::document_types::app_manifest::enc_bindings`. +Platform does not parse `requestedEncryptionKeys`; it is a byte array to consensus, and the +layout above is a convention between apps and wallets. The Rust crate exposes the offsets and the +purpose bits as constants under +`app_connect_contract::v1::document_types::app_manifest::requested_encryption_keys`. ## The login flow @@ -117,14 +135,15 @@ The app keeps the matching private key in memory until the answer arrives. ### The wallet -1. Fetches the app contract, then the manifest owned by the contract's owner whose - `appContractId` matches. Either missing, the request is refused. +1. Fetches the app contract, then the manifest whose `appContractId` matches. Either missing, + the request is refused. Consensus guarantees the manifest was written by the contract's + owner. 2. Lets the user choose an identity if the wallet holds more than one usable one. 3. Derives the session key for this request (its derivation leaf is the request id, so a different `e` yields a different key). If that key is already on the identity and not expired, the same request was already served: skip to step 6 with it. -4. For every `encBindings` record, checks the identity for the bound keys it asks for and plans - to add the missing ones. +4. For every `requestedEncryptionKeys` record, checks the identity for the bound keys it asks + for and plans to add the missing ones. 5. Shows the approval sheet: the app's name and URL, the key's lifetime and budget (the wallet's grant, which the user can shorten), the bounds, and any encryption keys that will be added. On approval, broadcasts one identity update that adds the login key with the manifest's @@ -142,14 +161,11 @@ that document, verifies that each granted key's public half is a live key on tha treats that identity as the logged-in user. It is logged in until the key expires or its budget runs out, at which point it starts a new `connect`. -**Residual risk.** ECDH to a public ephemeral key does not authenticate the sender: an observer -of the QR code can compute the shared secret and answer the request with keys of their own -identity. The identity check bounds what that achieves. The observer's keys are live only on the -observer's identity, so the app is logged into the observer's account, never the user's; the -observer gains nothing about the user and has spent a document fee. That is the standard -exposure of any unauthenticated pairing. The wallet's approval sheet is the pairing step, and -the identity the app shows after login is the user's confirmation that it paired with the right -wallet. +**Who logs in.** Whoever scans the request logs in, as with any QR or passkey login: if a +second party scans the same code first, the app is logged into that party's identity. The app +shows the identity it is logged in as; users verify it the same way they verify any account they +sign into. Platform gives no stronger binding because the request carries no user secret by +design (it is shown in the open). ### Signing outside the login key's scope @@ -161,18 +177,17 @@ does not touch this contract. ## What Platform enforces Platform validates both document types against their schema, keeps the manifest index unique, -checks that `contractId` and `appContractId` name existing contracts, and bills the writer. The -login key's scope, lifetime and budget are enforced by the identity key itself once registered. +checks that `contractId` and `appContractId` name existing contracts, refuses a manifest whose +writer is not the owner of the contract `appContractId` names, and bills the writer. The login +key's scope, lifetime and budget are enforced by the identity key itself once registered. What Platform does not check: -- that a manifest's owner owns the app contract (wallets check `$ownerId` against the - contract's owner); - the dependency between `authBoundsKind` and `authBoundsId` / `authBoundsDocType`: the schema admits a kind of `1`, `2` or `3` without an id, a kind of `2` without a document type, and an id or document type beside a kind of `0`. Wallets must refuse a manifest whose bounds fields do not match its kind; -- the layout of `encBindings`; +- the layout of `requestedEncryptionKeys`; - whether a response belongs to a real request, or was written by the wallet the request was made to. The app authenticates every response by decrypting it. diff --git a/packages/app-connect-contract/README.md b/packages/app-connect-contract/README.md index 9e51a931f73..62e021ab2b6 100644 --- a/packages/app-connect-contract/README.md +++ b/packages/app-connect-contract/README.md @@ -13,12 +13,15 @@ It gives the two halves of a login one well-known contract id on every network: unique (the request id is public, so uniqueness would let anyone block the wallet's write); the app authenticates each candidate by decrypting it. The wallet keeps its response's document id locally and replaces it on re-login. -- `appManifest`: published once by the owner of an app's data contract. Names +- `appManifest`: published once by the owner of an app's data contract. + Consensus refuses it from anyone else (`appContractId` carries the owner + gate `propertyAgreement: { "$ownerId": "$ownerId" }`), and it is unique + per app contract, so a wallet fetches it by `appContractId` alone. Names the app, states the contract bounds its login key must carry, the session - lifetime and budget it asks for, and the encryption key bindings it needs, - packed as fixed 96-byte records in `encBindings`. Wallets look it up by - `($ownerId, appContractId)`, so only the contract's owner can publish the - manifest for it. + lifetime and budget it asks for (a default the wallet may raise or + lower), and the encryption keys the wallet should register on the + identity at login, packed as fixed 96-byte records in + `requestedEncryptionKeys`. Both document types are created by ordinary identities (`creationRestrictionMode: 0`), mutable and deletable. The contract activates diff --git a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json index 5b47dbab095..0a3c91a998e 100644 --- a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json +++ b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json @@ -51,7 +51,7 @@ "byteArray": true, "minItems": 60, "maxItems": 572, - "description": "The session key material encrypted to the app: a 28-byte envelope followed by 32 bytes per key, one to seventeen keys (the session key plus up to eight encBindings records with both purposes)", + "description": "The session key material encrypted to the app: a 28-byte envelope followed by 32 bytes per key, one to seventeen keys (the session key plus up to eight requestedEncryptionKeys records with both purposes)", "position": 3 } }, @@ -71,11 +71,8 @@ "creationRestrictionMode": 0, "indices": [ { - "name": "byOwnerAndApp", + "name": "byApp", "properties": [ - { - "$ownerId": "asc" - }, { "appContractId": "asc" } @@ -91,9 +88,12 @@ "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier", "refersTo": { - "type": "contract" + "type": "contract", + "propertyAgreement": { + "$ownerId": "$ownerId" + } }, - "description": "The app's data contract; wallets only trust a manifest whose owner is this contract's owner", + "description": "The app's data contract; the owner gate means only that contract's owner can create or replace this manifest", "position": 0 }, "name": { @@ -132,21 +132,21 @@ "sessionSeconds": { "type": "integer", "minimum": 0, - "description": "The login key lifetime the app asks for, in seconds; the wallet may grant less", + "description": "The login key lifetime the app asks for, in seconds; the wallet treats it as a default and may grant less or more", "position": 6 }, "sessionBudget": { "type": "integer", "minimum": 0, - "description": "The login key budget the app asks for, in credits; the wallet may grant less", + "description": "The login key budget the app asks for, in credits; the wallet treats it as a default and may grant less or more", "position": 7 }, - "encBindings": { + "requestedEncryptionKeys": { "type": "array", "byteArray": true, "minItems": 0, "maxItems": 768, - "description": "Zero to eight fixed 96-byte records, each a contract id (32 bytes), a purpose mask (1 byte: bit 0 ENCRYPTION, bit 1 DECRYPTION) and a zero-padded document type name (63 bytes, all zero for a contract-level binding), naming where the app needs encryption keys bound", + "description": "The encryption keys the app needs the wallet to register on the identity at login, zero to eight fixed 96-byte records: a contract id (32 bytes), a purpose mask (1 byte: bit 0 ENCRYPTION, bit 1 DECRYPTION) and a zero-padded document type name (63 bytes, all zero for a contract-level key)", "position": 8 } }, @@ -158,7 +158,7 @@ "sessionSeconds", "sessionBudget" ], - "description": "An app's published identity and requirements for the connect handshake, created by the owner of the app's data contract. The bounds are requirements; the session lifetime and budget are requests that cap what the wallet grants.", + "description": "An app's published identity and requirements for the connect handshake; consensus lets only the owner of the app's data contract create or replace it. The bounds are requirements; the session lifetime and budget are the app's request, which the wallet treats as a default and may grant less or more.", "additionalProperties": false } } diff --git a/packages/app-connect-contract/src/v1/mod.rs b/packages/app-connect-contract/src/v1/mod.rs index e7dd50e714a..cf5e6b8e35a 100644 --- a/packages/app-connect-contract/src/v1/mod.rs +++ b/packages/app-connect-contract/src/v1/mod.rs @@ -29,11 +29,11 @@ pub mod document_types { pub const AUTH_BOUNDS_DOC_TYPE: &str = "authBoundsDocType"; pub const SESSION_SECONDS: &str = "sessionSeconds"; pub const SESSION_BUDGET: &str = "sessionBudget"; - pub const ENC_BINDINGS: &str = "encBindings"; + pub const REQUESTED_ENCRYPTION_KEYS: &str = "requestedEncryptionKeys"; } pub mod indexes { - pub const BY_OWNER_AND_APP: &str = "byOwnerAndApp"; + pub const BY_APP: &str = "byApp"; } /// Values of the `authBoundsKind` property: the contract bounds the app asks the @@ -50,11 +50,12 @@ pub mod document_types { pub const CONTRACT_GROUP: u8 = 3; } - /// Layout of the packed `encBindings` byte array: zero to - /// [`MAX_RECORDS`](enc_bindings::MAX_RECORDS) fixed-size records, each naming a - /// contract (or one of its document types) and which encryption key purposes the - /// app wants bound there. - pub mod enc_bindings { + /// Layout of the packed `requestedEncryptionKeys` byte array: zero to + /// [`MAX_RECORDS`](requested_encryption_keys::MAX_RECORDS) fixed-size records, + /// each naming a contract (or one of its document types) and which encryption + /// key purposes the app needs registered there. The app cannot register keys + /// on the user's identity; the wallet does that at login from this list. + pub mod requested_encryption_keys { /// Size of one record: contract id, purpose mask, document type name. pub const RECORD_SIZE: usize = 96; /// Maximum number of records, so the array is at most 768 bytes. @@ -69,7 +70,7 @@ pub mod document_types { /// Purpose mask bit asking for a DECRYPTION key bound there. pub const PURPOSE_DECRYPTION: u8 = 0b10; /// Byte offset and length of the zero-padded document type name within a - /// record; all zero for a contract-level binding. + /// record; all zero for a contract-level key. pub const DOCUMENT_TYPE_NAME_OFFSET: usize = 33; pub const DOCUMENT_TYPE_NAME_SIZE: usize = 63; } diff --git a/packages/app-connect-contract/test/unit/appConnectContract.spec.js b/packages/app-connect-contract/test/unit/appConnectContract.spec.js index c62a8ca4cab..0d9f0e8e3d3 100644 --- a/packages/app-connect-contract/test/unit/appConnectContract.spec.js +++ b/packages/app-connect-contract/test/unit/appConnectContract.spec.js @@ -197,7 +197,7 @@ describe('App Connect Contract', () => { }); it('should accept the maximum grant of seventeen keys', async () => { - // Session key plus eight encBindings records asking for both + // Session key plus eight requestedEncryptionKeys records asking for both // purposes: 28-byte envelope + 17 * 32 = 572 bytes. rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(28 + (17 * 32)); @@ -239,7 +239,7 @@ describe('App Connect Contract', () => { authBoundsId: crypto.randomBytes(32), sessionSeconds: 604800, sessionBudget: 10000000000, - encBindings: crypto.randomBytes(96 * 4), + requestedEncryptionKeys: crypto.randomBytes(96 * 4), }; }); @@ -449,9 +449,9 @@ describe('App Connect Contract', () => { }); }); - describe('encBindings', () => { + describe('requestedEncryptionKeys', () => { it('should be optional', async () => { - delete rawAppManifestDocument.encBindings; + delete rawAppManifestDocument.requestedEncryptionKeys; const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); @@ -460,7 +460,7 @@ describe('App Connect Contract', () => { }); it('should accept an empty array', async () => { - rawAppManifestDocument.encBindings = Buffer.alloc(0); + rawAppManifestDocument.requestedEncryptionKeys = Buffer.alloc(0); const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); @@ -469,7 +469,7 @@ describe('App Connect Contract', () => { }); it('should accept eight records', async () => { - rawAppManifestDocument.encBindings = crypto.randomBytes(96 * 8); + rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(96 * 8); const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); @@ -478,7 +478,7 @@ describe('App Connect Contract', () => { }); it('should be not longer than 768 bytes', async () => { - rawAppManifestDocument.encBindings = crypto.randomBytes(769); + rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(769); const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); From b801e500053b5f0a60ca6c465eeb40fc87d4abe4 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 20 Sep 2026 10:02:14 -0500 Subject: [PATCH 6/6] fix(app-connect): address review suggestions requestedEncryptionKeys records grow to 97 bytes so the 64-byte document type name Platform admits fits unpadded (schema maxItems 776; boundary tests at 776/777 and a 64-character round-trip). The trusted context provider serves the app-connect contract only from protocol 14, like Drive's find_by_id, through a shared APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION constant; DocumentHistory's branch had the same gap and gets the same gate at 13, each with provider tests for absence below and presence at the activation version. Docs: keys are provisioned in identity updates of at most six new keys (login key first, response published once every key is confirmed, partial failures resume from the missing keys), and the app accepts a response candidate only after both the tag and the live-key check on its owner pass. Co-Authored-By: Claude Fable 5.1 --- docs/protocol/app-connect.md | 42 ++++++---- packages/app-connect-contract/README.md | 2 +- .../v1/app-connect-contract-documents.json | 4 +- packages/app-connect-contract/src/v1/mod.rs | 9 ++- .../test/unit/appConnectContract.spec.js | 28 ++++++- .../rs-drive/src/cache/system_contracts.rs | 10 ++- .../feature_initial_protocol_versions.rs | 9 +++ .../src/provider.rs | 77 ++++++++++++++++++- 8 files changed, 150 insertions(+), 31 deletions(-) diff --git a/docs/protocol/app-connect.md b/docs/protocol/app-connect.md index 240539eb594..603e748cd64 100644 --- a/docs/protocol/app-connect.md +++ b/docs/protocol/app-connect.md @@ -72,7 +72,7 @@ rejected (`ReferencedDocumentPropertyMismatchError`). A wallet therefore fetches | `authBoundsDocType` | string, at most 64 characters, optional | The document type name, present only when `authBoundsKind` is `2`. | | `sessionSeconds` | integer | The login key lifetime the app asks for, in seconds. | | `sessionBudget` | integer | The login key budget the app asks for, in credits. | -| `requestedEncryptionKeys` | 0 to 768 bytes, optional | The encryption keys the wallet should register on the identity at login, packed as fixed 96-byte records (below). | +| `requestedEncryptionKeys` | 0 to 776 bytes, optional | The encryption keys the wallet should register on the identity at login, packed as fixed 97-byte records (below). | `appContractId`, `name`, `url`, `authBoundsKind`, `sessionSeconds` and `sessionBudget` are required. The single index, `byApp` on `(appContractId)`, is unique: one manifest per app @@ -101,15 +101,15 @@ keys the identity already holds, and adds the missing ones in the same identity login key. Document schemas admit byte arrays but not arrays of objects, so the list is packed. Each record -is 96 bytes: +is 97 bytes: | Offset | Size | Content | |---|---|---| | 0 | 32 | The id of the data contract the keys serve. | | 32 | 1 | Purpose mask: bit 0 asks for an ENCRYPTION key, bit 1 for a DECRYPTION key. | -| 33 | 63 | The document type name, zero-padded; all zero for a contract-level key. | +| 33 | 64 | The document type name, zero-padded (64 bytes is the longest name Platform admits, so every name fits); all zero for a contract-level key. | -The array holds zero to eight records, so its length is a multiple of 96 up to 768. For each +The array holds zero to eight records, so its length is a multiple of 97 up to 776. For each record and each purpose bit, the wallet makes sure the identity holds an enabled key with that purpose, bound to that contract (or that document type of it), and registers one if it does not. The bound contract or document type has to opt in with `requiresIdentityEncryptionBoundedKey` or @@ -140,26 +140,36 @@ The app keeps the matching private key in memory until the answer arrives. owner. 2. Lets the user choose an identity if the wallet holds more than one usable one. 3. Derives the session key for this request (its derivation leaf is the request id, so a - different `e` yields a different key). If that key is already on the identity and not - expired, the same request was already served: skip to step 6 with it. + different `e` yields a different key). If the login key is present on the identity and not + expired **and** every requested encryption key is present, the same request was already + served in full: skip to step 6 with them. If the login key is present but some requested + keys are not, a previous attempt was cut short after its first update; continue at step 4 + with what is missing. 4. For every `requestedEncryptionKeys` record, checks the identity for the bound keys it asks for and plans to add the missing ones. 5. Shows the approval sheet: the app's name and URL, the key's lifetime and budget (the wallet's grant, which the user can shorten), the bounds, and any encryption keys that will be added. - On approval, broadcasts one identity update that adds the login key with the manifest's - bounds and the granted limits, plus any missing bound encryption keys. -6. Encrypts the granted private keys to the app's ephemeral key and creates, or replaces, its - `loginKeyResponse` for this app. If the identity update failed, nothing is published. + On approval, provisions the keys in identity updates of at most six new keys each + (`max_public_keys_in_creation`): the login key goes in the first update, with as many of the + missing encryption keys as fit, and the rest follow in further updates. Each update is + waited for before the next is sent. A manifest that asks for eight records with both + purposes needs seventeen keys, three updates. +6. Once every granted key is confirmed on the identity, encrypts the granted private keys to + the app's ephemeral key and creates, or replaces, its `loginKeyResponse` for this app. If + any update failed, nothing is published: the keys that did land stay on the identity, and + the next attempt at the same request resumes from the missing ones (step 3). ### The app Polls the `loginKeyResponse` documents whose `contractId` is its own contract and whose -`appEphemeralPubKeyHash` is `hash160(e)`. For each result the app derives the shared secret from -`walletEphemeralPubKey` and tries to decrypt the payload; the first one whose authentication tag -verifies is the candidate answer, and the rest are discarded. The app then reads `$ownerId` from -that document, verifies that each granted key's public half is a live key on that identity, and -treats that identity as the logged-in user. It is logged in until the key expires or its budget -runs out, at which point it starts a new `connect`. +`appEphemeralPubKeyHash` is `hash160(e)`. For each candidate the app derives the shared secret +from `walletEphemeralPubKey` and tries to decrypt the payload; if the authentication tag +verifies, it reads `$ownerId` from that document and checks that each granted key's public half +is a live key on that identity. Only a candidate that passes both checks is the answer; the app +keeps scanning past one that decrypts but fails the key check (anyone who saw the request can +produce such a row), and discards the remaining candidates only once an answer is found. It +treats the answer's `$ownerId` as the logged-in user, until the key expires or its budget runs +out, at which point it starts a new `connect`. **Who logs in.** Whoever scans the request logs in, as with any QR or passkey login: if a second party scans the same code first, the app is logged into that party's identity. The app diff --git a/packages/app-connect-contract/README.md b/packages/app-connect-contract/README.md index 62e021ab2b6..25b973e0fd2 100644 --- a/packages/app-connect-contract/README.md +++ b/packages/app-connect-contract/README.md @@ -20,7 +20,7 @@ It gives the two halves of a login one well-known contract id on every network: the app, states the contract bounds its login key must carry, the session lifetime and budget it asks for (a default the wallet may raise or lower), and the encryption keys the wallet should register on the - identity at login, packed as fixed 96-byte records in + identity at login, packed as fixed 97-byte records in `requestedEncryptionKeys`. Both document types are created by ordinary identities diff --git a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json index 0a3c91a998e..37da5894b7b 100644 --- a/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json +++ b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json @@ -145,8 +145,8 @@ "type": "array", "byteArray": true, "minItems": 0, - "maxItems": 768, - "description": "The encryption keys the app needs the wallet to register on the identity at login, zero to eight fixed 96-byte records: a contract id (32 bytes), a purpose mask (1 byte: bit 0 ENCRYPTION, bit 1 DECRYPTION) and a zero-padded document type name (63 bytes, all zero for a contract-level key)", + "maxItems": 776, + "description": "The encryption keys the app needs the wallet to register on the identity at login, zero to eight fixed 97-byte records: a contract id (32 bytes), a purpose mask (1 byte: bit 0 ENCRYPTION, bit 1 DECRYPTION) and a zero-padded document type name (64 bytes, the longest name Platform admits; all zero for a contract-level key)", "position": 8 } }, diff --git a/packages/app-connect-contract/src/v1/mod.rs b/packages/app-connect-contract/src/v1/mod.rs index cf5e6b8e35a..cb19c5e1d53 100644 --- a/packages/app-connect-contract/src/v1/mod.rs +++ b/packages/app-connect-contract/src/v1/mod.rs @@ -57,8 +57,8 @@ pub mod document_types { /// on the user's identity; the wallet does that at login from this list. pub mod requested_encryption_keys { /// Size of one record: contract id, purpose mask, document type name. - pub const RECORD_SIZE: usize = 96; - /// Maximum number of records, so the array is at most 768 bytes. + pub const RECORD_SIZE: usize = 97; + /// Maximum number of records, so the array is at most 776 bytes. pub const MAX_RECORDS: usize = 8; /// Byte offset and length of the contract id within a record. pub const CONTRACT_ID_OFFSET: usize = 0; @@ -70,9 +70,10 @@ pub mod document_types { /// Purpose mask bit asking for a DECRYPTION key bound there. pub const PURPOSE_DECRYPTION: u8 = 0b10; /// Byte offset and length of the zero-padded document type name within a - /// record; all zero for a contract-level key. + /// record; all zero for a contract-level key. 64 bytes is the longest + /// document type name Platform admits, so every name fits unpadded. pub const DOCUMENT_TYPE_NAME_OFFSET: usize = 33; - pub const DOCUMENT_TYPE_NAME_SIZE: usize = 63; + pub const DOCUMENT_TYPE_NAME_SIZE: usize = 64; } } } diff --git a/packages/app-connect-contract/test/unit/appConnectContract.spec.js b/packages/app-connect-contract/test/unit/appConnectContract.spec.js index 0d9f0e8e3d3..14e50b2e3ed 100644 --- a/packages/app-connect-contract/test/unit/appConnectContract.spec.js +++ b/packages/app-connect-contract/test/unit/appConnectContract.spec.js @@ -239,7 +239,7 @@ describe('App Connect Contract', () => { authBoundsId: crypto.randomBytes(32), sessionSeconds: 604800, sessionBudget: 10000000000, - requestedEncryptionKeys: crypto.randomBytes(96 * 4), + requestedEncryptionKeys: crypto.randomBytes(97 * 4), }; }); @@ -469,7 +469,7 @@ describe('App Connect Contract', () => { }); it('should accept eight records', async () => { - rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(96 * 8); + rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(97 * 8); const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); @@ -477,8 +477,28 @@ describe('App Connect Contract', () => { expect(validationResult.isValid()).to.be.true(); }); - it('should be not longer than 768 bytes', async () => { - rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(769); + it('should carry a 64-character document type name unpadded in a record', async () => { + // The longest document type name Platform admits fills the name + // field exactly: 32-byte contract id, 1-byte purpose mask, 64-byte name. + const name = 'a'.repeat(64); + const record = Buffer.concat([ + crypto.randomBytes(32), + Buffer.from([0b11]), + Buffer.from(name, 'ascii'), + ]); + expect(record.length).to.equal(97); + rawAppManifestDocument.requestedEncryptionKeys = record; + + const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); + const validationResult = document.validate(dpp.protocolVersion); + expect(validationResult.isValid()).to.be.true(); + + const stored = Buffer.from(document.get('requestedEncryptionKeys')); + expect(stored.subarray(33, 97).toString('ascii')).to.equal(name); + }); + + it('should be not longer than 776 bytes', async () => { + rawAppManifestDocument.requestedEncryptionKeys = crypto.randomBytes(777); const document = dpp.document.create(dataContract, identityId, 'appManifest', rawAppManifestDocument); const validationResult = document.validate(dpp.protocolVersion); diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index 0a255358c4e..395a765bb0b 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -3,6 +3,10 @@ use arc_swap::ArcSwap; use dpp::data_contract::DataContract; use dpp::prelude::Identifier; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use platform_version::version::feature_initial_protocol_versions::{ + APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, + DOCUMENT_HISTORY_CONTRACT_INITIAL_PROTOCOL_VERSION, +}; use platform_version::version::{PlatformVersion, ProtocolVersion}; use std::collections::BTreeMap; use std::sync::Arc; @@ -204,9 +208,11 @@ impl SystemDataContracts { // Written to state by the transition to protocol version 9. SystemDataContract::TokenHistory | SystemDataContract::KeywordSearch => 9, // Written to state by the transition to protocol version 13. - SystemDataContract::DocumentHistory => 13, + SystemDataContract::DocumentHistory => { + DOCUMENT_HISTORY_CONTRACT_INITIAL_PROTOCOL_VERSION + } // Written to state by the transition to protocol version 14. - SystemDataContract::AppConnect => 14, + SystemDataContract::AppConnect => APP_CONNECT_CONTRACT_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), diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 0a8ade18b58..ba795bd4f31 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -17,3 +17,12 @@ pub const CONTRACT_USER_MODERATION_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1 /// The protocol version that introduces document action fees and the `ContractFeeClaim` state /// transition, which pays out the fee pots they collect in. pub const CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; + +/// The document history system contract is written to state by the upgrade to protocol +/// version 13 and registered at genesis from that version on; below it the contract does +/// not exist and lookups must report it absent. +pub const DOCUMENT_HISTORY_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 13; +/// The app-connect system contract is written to state by the upgrade to protocol +/// version 14 and registered at genesis from that version on; below it the contract does +/// not exist and lookups must report it absent. +pub const APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 28263e6045d..523397e288d 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -22,6 +22,13 @@ use dpp::data_contract::TokenConfiguration; feature = "all-system-contracts" ))] use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +#[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( + feature = "document-history-contract", + feature = "all-system-contracts" +))] +use dpp::version::feature_initial_protocol_versions::DOCUMENT_HISTORY_CONTRACT_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use lru::LruCache; @@ -875,7 +882,13 @@ impl ContextProvider for TrustedHttpContextProvider { feature = "document-history-contract", feature = "all-system-contracts" ))] - if *id == SystemDataContract::DocumentHistory.id() { + // Absent below its activation version, as Drive's system contract cache reports + // it: the contract does not exist in state there, and its schema is not + // expressible under the older meta-schema, so materializing it would fail. + if *id == SystemDataContract::DocumentHistory.id() + && platform_version.protocol_version + >= DOCUMENT_HISTORY_CONTRACT_INITIAL_PROTOCOL_VERSION + { return load_system_data_contract( SystemDataContract::DocumentHistory, platform_version, @@ -890,7 +903,12 @@ impl ContextProvider for TrustedHttpContextProvider { } #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::AppConnect.id() { + // Same activation gate: below protocol version 14 the app-connect contract is + // absent, so the lookup falls through to the fallback provider (or `None`). + if *id == SystemDataContract::AppConnect.id() + && platform_version.protocol_version + >= APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION + { return load_system_data_contract(SystemDataContract::AppConnect, platform_version) .map(|contract| Some(Arc::new(contract))) .map_err(|e| { @@ -1566,6 +1584,61 @@ mod tests { // The builder pattern is more appropriate since contracts are only added during initialization } + /// Compiled-in system contracts are served only from their activation version on, + /// matching Drive's `SystemDataContracts::find_by_id`: below it the contract does not + /// exist in state, and its schema would not even parse under the older meta-schema. + #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] + #[test] + fn test_app_connect_contract_is_absent_before_protocol_14() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::version::PlatformVersion; + + let provider = TrustedHttpContextProvider::new( + Network::Testnet, + None, + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let id = SystemDataContract::AppConnect.id(); + + assert!(provider + .get_data_contract(&id, PlatformVersion::get(13).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + + let contract = provider + .get_data_contract(&id, PlatformVersion::get(14).unwrap()) + .expect("the lookup must succeed at protocol version 14") + .expect("the app-connect contract must be served at protocol version 14"); + assert_eq!(contract.id(), id); + } + + #[cfg(any( + feature = "document-history-contract", + feature = "all-system-contracts" + ))] + #[test] + fn test_document_history_contract_is_absent_before_protocol_13() { + use dpp::version::PlatformVersion; + + let provider = TrustedHttpContextProvider::new( + Network::Testnet, + None, + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let id = SystemDataContract::DocumentHistory.id(); + + assert!(provider + .get_data_contract(&id, PlatformVersion::get(12).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + assert!(provider + .get_data_contract(&id, PlatformVersion::get(13).unwrap()) + .expect("the lookup must succeed at protocol version 13") + .is_some()); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost)